diff --git a/cypress/e2e/dashboard/dashboard.cy.ts b/cypress/e2e/dashboard/dashboard.cy.ts
new file mode 100644
index 0000000..8a63567
--- /dev/null
+++ b/cypress/e2e/dashboard/dashboard.cy.ts
@@ -0,0 +1,228 @@
+///
+
+const apiResponse = (data: Record) => ({
+ success: true,
+ message: "ok",
+ data,
+});
+
+const visitDashboard = () => {
+ cy.visit("/", {
+ onBeforeLoad(win) {
+ cy.stub(win, "matchMedia").callsFake((query: string) => ({
+ matches: query === "(prefers-reduced-motion: reduce)",
+ media: query,
+ onchange: null,
+ addEventListener: () => undefined,
+ removeEventListener: () => undefined,
+ addListener: () => undefined,
+ removeListener: () => undefined,
+ dispatchEvent: () => false,
+ }));
+ },
+ });
+};
+
+const stubDashboardRequests = () => {
+ const now = Math.floor(Date.now() / 1000);
+
+ cy.intercept("GET", "**/api/proxy/dashboard/summary**", (req) => {
+ expect(req.query.closing_window).to.be.undefined;
+ req.reply({
+ statusCode: 200,
+ body: apiResponse({
+ total_tenders: 120,
+ active_tenders: 72,
+ awarded_tenders: 18,
+ expired_tenders: 20,
+ cancelled_tenders: 10,
+ closing_soon: 12,
+ total_estimated_value: 2500000,
+ value_currency: "EUR",
+ generated_at: now,
+ }),
+ });
+ }).as("dashboardSummary");
+
+ cy.intercept("GET", "**/api/proxy/dashboard/trend**", (req) => {
+ expect(req.query).to.include({ days: "14", metric: "created" });
+ req.reply({
+ statusCode: 200,
+ body: apiResponse({
+ metric: "created",
+ days: 14,
+ series: [
+ { date: "2026-06-07", count: 4 },
+ { date: "2026-06-08", count: 7 },
+ { date: "2026-06-09", count: 9 },
+ ],
+ }),
+ });
+ }).as("dashboardTrend");
+
+ cy.intercept("GET", "**/api/proxy/dashboard/countries**", (req) => {
+ expect(req.query.limit).to.eq("6");
+ req.reply({
+ statusCode: 200,
+ body: apiResponse({
+ total: 120,
+ items: [
+ { country_code: "DE", count: 60 },
+ { country_code: "FR", count: 30 },
+ { country_code: "NL", count: 10 },
+ ],
+ other_count: 20,
+ }),
+ });
+ }).as("dashboardCountries");
+
+ cy.intercept("GET", "**/api/proxy/dashboard/notice-types**", {
+ statusCode: 200,
+ body: apiResponse({
+ total: 100,
+ items: [
+ { type: "CONTRACT_NOTICE", count: 70 },
+ { type: "AWARD_NOTICE", count: 30 },
+ ],
+ }),
+ }).as("dashboardNoticeTypes");
+
+ cy.intercept("GET", "**/api/proxy/dashboard/closing-soon**", (req) => {
+ expect(req.query.limit).to.eq("5");
+ req.reply({
+ statusCode: 200,
+ body: apiResponse({
+ items: [
+ {
+ id: "closing-tender-1",
+ title: "Railway signaling upgrade",
+ country_code: "DE",
+ buyer_name: "Federal Rail Agency",
+ submission_deadline: now + 36 * 60 * 60,
+ status: "active",
+ },
+ ],
+ }),
+ });
+ }).as("dashboardClosingSoon");
+
+ cy.intercept("GET", "**/api/proxy/tenders**", (req) => {
+ expect(req.query).to.include({
+ sort_by: "created_at",
+ sort_order: "desc",
+ offset: "0",
+ limit: "5",
+ });
+ req.reply({
+ statusCode: 200,
+ body: apiResponse({
+ tenders: [
+ {
+ id: "recent-tender-1",
+ title: "Hospital equipment framework",
+ country_code: "FR",
+ status: "active",
+ created_at: now - 60 * 60,
+ buyer_organization: { name: "Central Health Buyer" },
+ },
+ ],
+ }),
+ });
+ }).as("recentTenders");
+
+ cy.intercept("GET", "**/api/proxy/dashboard/statistics**", (req) => {
+ const days = Number(req.query.days);
+ req.alias = days === 30 ? "statistics30Days" : "statistics14Days";
+ req.reply({
+ statusCode: 200,
+ body: apiResponse({
+ days,
+ generated_at: now,
+ daily: {
+ scraped_ted: [
+ { date: "2026-06-08", count: days === 30 ? 12 : 5 },
+ { date: "2026-06-09", count: days === 30 ? 18 : 8 },
+ ],
+ scraped_documents: [
+ { date: "2026-06-08", count: days === 30 ? 40 : 20 },
+ { date: "2026-06-09", count: days === 30 ? 60 : 30 },
+ ],
+ translated_notices: [
+ { date: "2026-06-08", count: days === 30 ? 7 : 3 },
+ { date: "2026-06-09", count: days === 30 ? 11 : 6 },
+ ],
+ },
+ totals: {
+ scraped_documents: days === 30 ? 9876 : 4321,
+ translated_tenders: days === 30 ? 654 : 321,
+ },
+ }),
+ });
+ });
+};
+
+describe("tender dashboard", () => {
+ beforeEach(() => {
+ cy.setAuthCookies();
+ stubDashboardRequests();
+ });
+
+ it("renders the new dashboard sections with API data and detail links", () => {
+ visitDashboard();
+
+ cy.wait([
+ "@dashboardSummary",
+ "@dashboardTrend",
+ "@dashboardCountries",
+ "@dashboardNoticeTypes",
+ "@dashboardClosingSoon",
+ "@recentTenders",
+ "@statistics14Days",
+ ]);
+
+ cy.get('h1[aria-label*="welcome to OppLens"]')
+ .should("be.visible")
+ .and("have.attr", "aria-label")
+ .and("include", "welcome to OppLens");
+
+ cy.contains("Total Tenders").parent().should("contain.text", "120");
+ cy.contains("Active Now").parent().should("contain.text", "72");
+ cy.contains("Closing Soon").parent().should("contain.text", "12");
+ cy.contains("Estimated Value").parent().should("contain.text", "€2.5M");
+
+ cy.contains("Tender Flow — Last 14 days").should("be.visible");
+ cy.contains("By Country").should("be.visible");
+ cy.contains(/^DE$/).should("be.visible");
+ cy.contains("Contract Notice").should("be.visible");
+ cy.contains("+70 · 70%").should("be.visible");
+
+ cy.contains("Railway signaling upgrade")
+ .closest("a")
+ .should("have.attr", "href", "/tenders/closing-tender-1");
+ cy.contains("Hospital equipment framework")
+ .closest("a")
+ .should("have.attr", "href", "/tenders/recent-tender-1");
+
+ cy.contains("Scraping & Translation").should("be.visible");
+ cy.contains("Total Scraped Documents").parent().should("contain.text", "4,321");
+ cy.contains("Total Translated Tenders").parent().should("contain.text", "321");
+ cy.contains("Scraped TED per day").should("be.visible");
+ cy.contains("Scraped documents per day").should("be.visible");
+ cy.contains("Translated notices per day").should("be.visible");
+ });
+
+ it("reloads scraping and translation statistics for a selected range", () => {
+ visitDashboard();
+ cy.wait("@statistics14Days");
+
+ cy.contains("button", "14d").should("have.class", "bg-primary");
+ cy.contains("button", "30d").click();
+ cy.wait("@statistics30Days")
+ .its("request.query.days")
+ .should("eq", "30");
+
+ cy.contains("button", "30d").should("have.class", "bg-primary");
+ cy.contains("Total Scraped Documents").parent().should("contain.text", "9,876");
+ cy.contains("Total Translated Tenders").parent().should("contain.text", "654");
+ });
+});
diff --git a/cypress/e2e/notifications/create-notification.cy.ts b/cypress/e2e/notifications/create-notification.cy.ts
new file mode 100644
index 0000000..7258a3d
--- /dev/null
+++ b/cypress/e2e/notifications/create-notification.cy.ts
@@ -0,0 +1,166 @@
+///
+
+const emptyHistoryResponse = {
+ success: true,
+ message: "ok",
+ data: [],
+ meta: {
+ offset: 0,
+ limit: 10,
+ total: 0,
+ page: 1,
+ pages: 1,
+ has_more: false,
+ },
+};
+
+const stubNotificationReads = () => {
+ cy.intercept("GET", "**/api/proxy/notifications/my**", {
+ statusCode: 200,
+ body: emptyHistoryResponse,
+ });
+ cy.intercept("GET", /\/api\/proxy\/notifications(?:\?|$)/, {
+ statusCode: 200,
+ body: emptyHistoryResponse,
+ }).as("notificationHistory");
+};
+
+const chooseMultiSelectOption = (label: string, option: string) => {
+ cy.contains("label", new RegExp(`^${label}`, "i"))
+ .parent()
+ .find('div[tabindex="0"]')
+ .click();
+ cy.contains(
+ "div[class*='cursor-pointer']",
+ new RegExp(`^${option}$`),
+ ).click();
+};
+
+describe("create notification", () => {
+ beforeEach(() => {
+ cy.setAuthCookies();
+ stubNotificationReads();
+ });
+
+ it("renders breadcrumbs and validates required fields and an invalid link", () => {
+ cy.visit("/notification-history/create");
+
+ cy.getByCy("breadcrumb-link-dashboard").should("be.visible");
+ cy.getByCy("breadcrumb-link-notifications").should(
+ "have.attr",
+ "href",
+ "/notification-history",
+ );
+ cy.getByCy("breadcrumb-current-create-notification")
+ .should("be.visible")
+ .and("contain.text", "Create Notification");
+
+ cy.get('textarea[name="description"]').type(
+ "Description entered so custom validation can run.",
+ );
+ cy.getByCy("admin-form-submit-button").click();
+ cy.get("p")
+ .filter(
+ (_index, element) =>
+ element.textContent?.trim() === "This field is required",
+ )
+ .should("have.length", 4);
+
+ cy.get('input[name="link"]').type("not-a-valid-url");
+ cy.get('input[name="title"]').type("Validation test");
+ cy.get('select[name="target"]').select("all_users", { force: true });
+ cy.get('select[name="priority"]').select("medium", { force: true });
+ cy.get('select[name="type"]').select("info", { force: true });
+ cy.get('textarea[name="description"]')
+ .clear()
+ .type("A valid notification message.");
+ cy.getByCy("admin-form-submit-button").click();
+
+ cy.contains("This field does not match the required pattern").should(
+ "be.visible",
+ );
+ });
+
+ it("loads role recipients and submits the expected notification payload", () => {
+ cy.intercept("POST", "**/api/proxy/notifications", (req) => {
+ expect(req.body).to.deep.equal({
+ title: "Planned maintenance",
+ target: "specific_role",
+ recipient: ["admin"],
+ channels: ["email", "push"],
+ link: "https://example.com/maintenance",
+ priority: "important",
+ type: "warning",
+ description: "The platform will be unavailable during maintenance.",
+ schedule_at: 0,
+ });
+ req.reply({
+ statusCode: 201,
+ body: {
+ success: true,
+ message: "Notification created successfully",
+ data: {},
+ },
+ });
+ }).as("createNotification");
+
+ cy.visit("/notification-history");
+ cy.wait("@notificationHistory");
+ cy.getByCy("list-header-create-button").click();
+ cy.location("pathname").should("eq", "/notification-history/create");
+
+ cy.get('input[name="title"]').type("Planned maintenance");
+ cy.get('select[name="target"]').select("specific_role", { force: true });
+ chooseMultiSelectOption("recipient", "Admin");
+ chooseMultiSelectOption("channels", "Email");
+ chooseMultiSelectOption("channels", "Push");
+ cy.get('input[name="link"]').type("https://example.com/maintenance");
+ cy.get('select[name="priority"]').select("important", { force: true });
+ cy.get('select[name="type"]').select("warning", { force: true });
+ cy.get('textarea[name="description"]').type(
+ "The platform will be unavailable during maintenance.",
+ );
+
+ cy.getByCy("admin-form-submit-button").click();
+ cy.wait("@createNotification");
+
+ cy.location("pathname").should("eq", "/notification-history");
+ cy.contains("Notification created successfully").should("be.visible");
+ });
+
+ it("loads specific users from the API and clears recipients when target changes", () => {
+ cy.intercept("GET", "**/api/proxy/users**", (req) => {
+ expect(req.query.offset).to.eq("0");
+ req.reply({
+ statusCode: 200,
+ body: {
+ success: true,
+ message: "ok",
+ data: {
+ users: [
+ {
+ id: "admin-recipient-1",
+ full_name: "Notification Admin",
+ username: "notification-admin",
+ },
+ ],
+ },
+ meta: { offset: 0, limit: 10, total: 1 },
+ },
+ });
+ }).as("notificationRecipients");
+
+ cy.visit("/notification-history/create");
+ cy.get('select[name="target"]').select("specific_users", { force: true });
+ cy.wait("@notificationRecipients");
+
+ chooseMultiSelectOption("recipient", "Notification Admin");
+ cy.contains("span", "Notification Admin").should("be.visible");
+
+ cy.get('select[name="target"]').select("all_users", { force: true });
+ cy.contains("label", /^recipient/i)
+ .parent()
+ .find('[tabindex="-1"]')
+ .should("exist");
+ });
+});
diff --git a/cypress/e2e/notifications/notification-history.cy.ts b/cypress/e2e/notifications/notification-history.cy.ts
new file mode 100644
index 0000000..c54f681
--- /dev/null
+++ b/cypress/e2e/notifications/notification-history.cy.ts
@@ -0,0 +1,190 @@
+///
+
+type NotificationRecord = {
+ id: string;
+ title: string;
+ event_type: string;
+ created_at: number;
+ message: string;
+ priority: string;
+ type: string;
+ seen: boolean;
+ status: string;
+ recipient?: { full_name: string };
+};
+
+const makeNotification = (
+ overrides: Partial = {},
+): NotificationRecord => ({
+ id: "notification-1",
+ title: "Tender deadline reminder",
+ event_type: "push",
+ created_at: 1_717_934_400,
+ message: "A watched tender is closing soon and needs your attention.",
+ priority: "important",
+ type: "warning",
+ seen: false,
+ status: "sent",
+ recipient: { full_name: "E2E Admin One" },
+ ...overrides,
+});
+
+const historyResponse = (
+ notifications: NotificationRecord[],
+ options: { offset?: number; total?: number; pages?: number } = {},
+) => ({
+ success: true,
+ message: "ok",
+ data: notifications,
+ meta: {
+ offset: options.offset ?? 0,
+ limit: 10,
+ total: options.total ?? notifications.length,
+ page: Math.floor((options.offset ?? 0) / 10) + 1,
+ pages: options.pages ?? 1,
+ has_more: false,
+ },
+});
+
+const stubHeaderNotifications = () => {
+ cy.intercept("GET", "**/api/proxy/notifications/my**", {
+ statusCode: 200,
+ body: historyResponse([]),
+ }).as("myNotifications");
+};
+
+describe("notification history", () => {
+ beforeEach(() => {
+ cy.setAuthCookies();
+ stubHeaderNotifications();
+ });
+
+ it("renders breadcrumbs, actions, columns, API rows, and details navigation", () => {
+ const notification = makeNotification();
+
+ cy.intercept("GET", /\/api\/proxy\/notifications(?:\?|$)/, (req) => {
+ expect(req.query).to.include({
+ sort_by: "created_at",
+ sort_order: "desc",
+ offset: "0",
+ limit: "10",
+ });
+ req.reply({
+ statusCode: 200,
+ body: historyResponse([notification]),
+ });
+ }).as("notificationHistory");
+
+ cy.visit("/notification-history");
+ cy.wait("@notificationHistory");
+
+ cy.getByCy("breadcrumb-link-dashboard").should("be.visible");
+ cy.getByCy("breadcrumb-current-notification-history")
+ .should("be.visible")
+ .and("contain.text", "Notification history");
+ cy.getByCy("list-header-create-button").should(
+ "contain.text",
+ "Create notification",
+ );
+
+ [
+ "row",
+ "title",
+ "channel",
+ "created at",
+ "receiver",
+ "message",
+ "priority",
+ "type",
+ "seen",
+ "status",
+ "actions",
+ ].forEach((column) => {
+ cy.contains("th", new RegExp(`^${column}$`, "i")).should("be.visible");
+ });
+
+ cy.contains("tr", notification.title)
+ .should("contain.text", notification.event_type)
+ .and("contain.text", notification.recipient?.full_name)
+ .and("contain.text", notification.priority)
+ .and("contain.text", notification.type)
+ .and("contain.text", notification.status)
+ .within(() => {
+ cy.get('button[data-tooltip-id="details"]').click();
+ });
+
+ cy.location("pathname").should(
+ "eq",
+ `/notification-history/${notification.id}`,
+ );
+ });
+
+ it("filters by seen state and search, then resets to the default list", () => {
+ const defaultNotification = makeNotification({
+ id: "notification-default",
+ title: "Default notification",
+ });
+ const filteredNotification = makeNotification({
+ id: "notification-filtered",
+ title: "Filtered urgent notice",
+ seen: true,
+ });
+
+ cy.intercept("GET", /\/api\/proxy\/notifications(?:\?|$)/, (req) => {
+ const isFiltered =
+ req.query.seen === "true" && req.query.search === "urgent";
+ req.alias = isFiltered ? "filteredNotifications" : "defaultNotifications";
+ req.reply({
+ statusCode: 200,
+ body: historyResponse([
+ isFiltered ? filteredNotification : defaultNotification,
+ ]),
+ });
+ });
+
+ cy.visit("/notification-history");
+ cy.wait("@defaultNotifications");
+ cy.contains(defaultNotification.title).should("be.visible");
+
+ cy.getByCy("notification-history-filter-panel-toggle").click();
+ cy.get('select[name="seen"]').select("true", { force: true });
+ cy.getByCy("notification-history-filter-search-input").type("urgent");
+ cy.getByCy("notification-history-filter-submit-button").click();
+
+ cy.wait("@filteredNotifications");
+ cy.location("search")
+ .should("include", "seen=true")
+ .and("include", "search=urgent");
+ cy.contains(filteredNotification.title).should("be.visible");
+ cy.contains(defaultNotification.title).should("not.exist");
+ cy.contains("2 active").should("be.visible");
+
+ cy.getByCy("notification-history-filter-reset-button").click();
+ cy.wait("@defaultNotifications");
+ cy.location("search").should("eq", "");
+ cy.contains(defaultNotification.title).should("be.visible");
+ });
+
+ it("shows loading and empty states, and opens the create page", () => {
+ cy.intercept("GET", /\/api\/proxy\/notifications(?:\?|$)/, (req) => {
+ req.reply({
+ delay: 900,
+ statusCode: 200,
+ body: historyResponse([]),
+ });
+ }).as("emptyNotificationHistory");
+
+ cy.visit("/notification-history");
+ cy.getByCy("table-skeleton").should("be.visible");
+ cy.wait("@emptyNotificationHistory");
+ cy.getByCy("table-skeleton").should("not.exist");
+ cy.getByCy("empty-list-message").should(
+ "contain.text",
+ "No notifications were found",
+ );
+
+ cy.getByCy("list-header-create-button").click();
+ cy.location("pathname").should("eq", "/notification-history/create");
+ cy.getByCy("breadcrumb-current-create-notification").should("be.visible");
+ });
+});
diff --git a/docs/client/create-company-field-validations.md b/docs/client/create-company-field-validations.md
new file mode 100644
index 0000000..4eadc04
--- /dev/null
+++ b/docs/client/create-company-field-validations.md
@@ -0,0 +1,32 @@
+# Create Company Field Validations
+
+This document describes validation rules for selected fields in the `Create Company` API request body (`POST /admin/v1/companies`).
+
+Source of truth: `internal/company/form.go` (`CompanyForm`).
+
+## employee_count
+
+- JSON field: `employee_count`
+- Type: `integer` (`*int`, optional)
+- Validation tag: `optional,range(1|100000)`
+- Rules:
+ - Field is optional.
+ - If provided, value must be between `1` and `100000` (inclusive).
+
+## annual_revenue
+
+- JSON field: `annual_revenue`
+- Type: `number` (`*float64`, optional)
+- Validation tag: `optional,range(0|999999999999)`
+- Rules:
+ - Field is optional.
+ - If provided, value must be between `0` and `999999999999` (inclusive).
+
+## founded_year
+
+- JSON field: `founded_year`
+- Type: `integer` (`*int`, optional)
+- Validation tag: `optional,range(1800|2100)`
+- Rules:
+ - Field is optional.
+ - If provided, value must be between `1800` and `2100` (inclusive).
diff --git a/src/components/forms/companies/CreateCompany.tsx b/src/components/forms/companies/CreateCompany.tsx
index fbb3575..e38e238 100644
--- a/src/components/forms/companies/CreateCompany.tsx
+++ b/src/components/forms/companies/CreateCompany.tsx
@@ -25,6 +25,35 @@ const FieldError = ({ message }: { message?: string }) =>
{message}
) : null;
+const optionalNumber = (value: unknown) => {
+ if (value === "" || value === null || value === undefined) return undefined;
+ return Number(value);
+};
+
+const optionalNumberRange = ({
+ min,
+ max,
+ integer = false,
+}: {
+ min: number;
+ max: number;
+ integer?: boolean;
+}) => {
+ return (value: unknown) => {
+ const numberValue = optionalNumber(value);
+
+ if (numberValue === undefined) return true;
+ if (!Number.isFinite(numberValue)) return "Enter a valid number";
+ if (integer && !Number.isInteger(numberValue)) {
+ return "This field must be a whole number";
+ }
+ if (numberValue < min) return FormErrorMessages.min(min);
+ if (numberValue > max) return FormErrorMessages.max(max);
+
+ return true;
+ };
+};
+
const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
const {
errors,
@@ -223,8 +252,12 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
>
{
/>
{
/>
{
const { mutate: updateCompany, isPending: isUpdating } = useUpdateCompany(
id as string,
);
+
+ const optionalNumber = (value: unknown) => {
+ if (value === "" || value === null || value === undefined) return undefined;
+
+ return Number(value);
+ };
+
const onSubmit: SubmitHandler = (data) => {
const formattedData = {
...data,
- founded_year: data.founded_year ? +data.founded_year : 0,
- employee_count: data.employee_count ? +data.employee_count : 0,
- annual_revenue: data.annual_revenue ? +data.annual_revenue : 0,
+ founded_year: optionalNumber(data.founded_year),
+ employee_count: optionalNumber(data.employee_count),
+ annual_revenue: optionalNumber(data.annual_revenue),
document_file_ids: data.document_file_ids ?? [],
tags: {
keywords: data?.tags?.keywords?.length ? data?.tags?.keywords : [],
diff --git a/src/lib/api/types/Company.ts b/src/lib/api/types/Company.ts
index b16e44f..9861ee8 100644
--- a/src/lib/api/types/Company.ts
+++ b/src/lib/api/types/Company.ts
@@ -35,9 +35,9 @@ export interface ICreateCompanyCredentials {
website: string;
type: string;
description: string;
- founded_year: number;
- employee_count: number;
- annual_revenue: number;
+ founded_year?: number | string;
+ employee_count?: number | string;
+ annual_revenue?: number | string;
currency: string;
industry: string;
language: string;
diff --git a/src/lib/api/types/TCompany.ts b/src/lib/api/types/TCompany.ts
index 370e20c..ad6117d 100644
--- a/src/lib/api/types/TCompany.ts
+++ b/src/lib/api/types/TCompany.ts
@@ -35,9 +35,9 @@ export interface ICreateCompanyCredentials {
website: string;
type: string;
description: string;
- founded_year: number;
- employee_count: number;
- annual_revenue: number;
+ founded_year?: number | string;
+ employee_count?: number | string;
+ annual_revenue?: number | string;
currency: string;
industry: string;
language: string;