Compare commits

...

30 Commits

Author SHA1 Message Date
AmirReza Jamali 5155c42146 feat(tenders): scroll to top on pagination change
continuous-integration/drone/push Build is passing
Smoothly scroll the page to the top when users move between tender list pages, respecting reduced-motion preferences.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 16:14:33 +03:30
AmirReza Jamali b87cf35f08 fix(tenders): clear documents scraped checkbox on filter reset
continuous-integration/drone/push Build is passing
Control the checkbox through react-hook-form so Reset all updates the UI, and normalize documents_scraped to false when rebuilding filter state.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 15:20:53 +03:30
AmirReza Jamali 209acea88a fix(notifications): dismiss bell badge when dropdown opens
continuous-integration/drone/push Build is passing
Hide the unread count badge after the user opens notifications, and show it again only when new unread items arrive.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 15:11:54 +03:30
AmirReza Jamali e3f1a0d1d2 fix(tenders): correct cursor pagination and language query handling
continuous-integration/drone/push Build is passing
Stop double-counting cursor stack depth so tender list pages advance sequentially, and only pass a language to tender details when lang is present in the URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 10:43:13 +03:30
AmirReza Jamali 7dc338dfcd fix(companies): allow missing industry
continuous-integration/drone/push Build is passing
2026-07-11 16:31:25 +03:30
AmirReza Jamali a762a14026 feat(admin): optimize tender list pagination
continuous-integration/drone/push Build is passing
2026-07-11 15:46:19 +03:30
AmirReza Jamali 624614f1a9 feat add company links
continuous-integration/drone/push Build is passing
2026-07-11 15:34:13 +03:30
AmirReza Jamali 37aa916ad5 fix notification badge initialization
continuous-integration/drone/push Build is passing
2026-07-11 14:56:05 +03:30
AmirReza Jamali 4051cb156c fix: improve inquiry status change and notification refresh
continuous-integration/drone/push Build is passing
Block no-op inquiry status updates, refetch active my-notifications after create, and use the shared error text token on Select validation messages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 16:09:50 +03:30
AmirReza Jamali 5b43741d74 Fix marketing validation and filter reset issues
continuous-integration/drone/push Build is passing
2026-07-07 12:55:41 +03:30
AmirReza Jamali 08938ad7f0 Fix company document removal in edit mode
continuous-integration/drone/push Build is passing
2026-07-07 10:54:51 +03:30
AmirReza Jamali 98fac2c867 Use timezone dropdown for company form
continuous-integration/drone/push Build is passing
2026-07-04 13:52:43 +03:30
AmirReza Jamali 3933efeddb Make company address fields optional
continuous-integration/drone/push Build is passing
2026-07-04 13:10:23 +03:30
AmirReza Jamali fc0d45e909 Migrate TED dashboard chart to summary API
continuous-integration/drone/push Build is passing
2026-07-04 12:51:28 +03:30
AmirReza Jamali 6b6dd7d2d1 fix(companies): clear currency filter on reset all
continuous-integration/drone/push Build is passing
Reset currency to an empty string instead of undefined so React Hook Form
overwrites the stored value and the controlled currency Select clears.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:03:54 +03:30
AmirReza Jamali 0a28a1226f fix(customers): clear filter text inputs on reset all
continuous-integration/drone/push Build is passing
Reset full_name and email to empty strings instead of undefined so React
Hook Form clears the uncontrolled input DOM values, matching the companies
filter reset behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:26:11 +03:30
AmirReza Jamali 84482f3952 fix(forms): default company currency to USD instead of invalid "en"
continuous-integration/drone/push Build is passing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:50:28 +03:30
AmirReza Jamali 15315ebb47 fix(forms): make company tax id, registration number, and industry optional
continuous-integration/drone/push Build is passing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:28:09 +03:30
AmirReza Jamali 5b5150e7da Fix notification user filtering
continuous-integration/drone/push Build is passing
2026-06-27 18:28:49 +03:30
AmirReza Jamali 561931876f fix(forms): add loading state for company document removal
continuous-integration/drone/push Build is passing
Block uploads and other document actions while a file is being removed, and show per-item and global loading feedback until deletion completes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 10:31:24 +03:30
AmirReza Jamali a63ba453d7 fix(auth): sync navbar user after self admin edit
continuous-integration/drone/push Build is passing
Keep the logged-in user context and cookie in sync when an admin updates their own profile so the navbar reflects name changes immediately.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 09:37:31 +03:30
AmirReza Jamali 9202f9a271 feat(dashboard): add scrape portals widget to dashboard
continuous-integration/drone/push Build is passing
Surface AI pipeline portal sources on the dashboard instead of a standalone page.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 10:22:58 +03:30
AmirReza Jamali 31d80cd6ae refactor(forms): memoize dynamic form sections with stable ids
continuous-integration/drone/push Build is passing
Add uuid-based section keys and memoized DynamicSection to reduce re-renders, fix dashboard GSAP scoping, update logo sizing, and temporarily hide notice-type and closing-soon widgets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 09:50:27 +03:30
AmirReza Jamali 90a42b9fc5 feat(forms): implement dynamic form builder for admin and company creation
continuous-integration/drone/push Build is passing
- Introduced DynamicFormBuilder component to streamline form creation for admin and company categories.
- Refactored CreateAdmin and CreateCompany components to utilize the new dynamic form structure.
- Added createAdminFormSections and createCompanyFormSections functions to define form fields and validation rules.
- Enhanced form handling with improved error management and dynamic field rendering.

This update enhances maintainability and scalability of forms across the application.
2026-06-21 09:03:51 +03:30
AmirReza Jamali 5a6a5346c6 Fix company list response schema
continuous-integration/drone/push Build is passing
2026-06-16 15:06:40 +03:30
AmirReza Jamali d9cd09eeb8 chore(env): update production app version to 2.3.3
continuous-integration/drone/push Build is passing
Bump NEXT_PUBLIC_APP_VERSION for the next production release.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 12:53:02 +03:30
AmirReza Jamali 169708e49d fix(companies): align optional numeric fields with API validation
continuous-integration/drone/push Build is passing
Make employee_count, annual_revenue, and founded_year truly optional in
the create company form and payload. Add field validation docs and
Cypress e2e coverage for dashboard and notifications.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 11:47:45 +03:30
AmirReza Jamali 2fec8bb84c Fix admin profile image removal
continuous-integration/drone/push Build is passing
2026-06-16 11:18:53 +03:30
mazyar 0af234b70b Update .drone.yml
continuous-integration/drone/push Build is passing
2026-06-15 09:58:01 +00:00
mazyar 82b6e1b3d3 Update .drone.yml
continuous-integration/drone Build is failing
2026-06-15 09:04:14 +00:00
79 changed files with 3858 additions and 1728 deletions
+42 -38
View File
@@ -1,60 +1,64 @@
################################################################################
# web
################################################################################
kind: pipeline kind: pipeline
type: docker type: docker
name: (admin Panel) build form and push to artifactory name: build-and-push-panel
steps: steps:
- name: build node app - name: build node app
image: docker-mirror.ravanertebat.ir/base/node/node-cypress4:22.14 image: cypress/included:14.5.4
environment:
PNPM_CONFIG_REGISTRY: https://mirror.ravanertebat.ir/repository/npm-proxy/
#NPM_CONFIG_STRICT_SSL: false
#NPM_CONFIG_AUDIT: false
commands: commands:
- | - |
set -e set -e
case "$DRONE_BRANCH" in SHORT_SHA=$(echo "$DRONE_COMMIT_SHA" | cut -c1-8)
pro) echo prod;; IMG_TAG="${DRONE_BUILD_NUMBER}-${DRONE_BRANCH}"
uat) echo uat;; echo -n "$IMG_TAG,$SHORT_SHA" > .tags
test) echo test;; echo "Image tags -> $IMG_TAG , $SHORT_SHA"
*) echo dev;; - corepack enable
esac > .environment - corepack prepare pnpm@10.18.0 --activate
- echo "Branch ${DRONE_BRANCH}, Environment $(cat .environment)"
- APP_VERSION=$(cat .environment)-${DRONE_BUILD_NUMBER}
- echo "APP Version tags $APP_VERSION"
- mkdir -p ./artifacts/node - mkdir -p ./artifacts/node
- rm -rf .git - rm -rf .git dist
# - corepack enable
# - corepack prepare pnpm@10.18.0 --activate
# - pnpm config list
- rm -rf dist
- pnpm install --frozen-lockfile --reporter=append-only - pnpm install --frozen-lockfile --reporter=append-only
- pnpm run test:e2e
- pnpm run build - pnpm run build
- cp -R .next/standalone/. ./artifacts/node/ - cp -R .next/standalone/. ./artifacts/node/
- ls -ltrha ./artifacts/node - ls -ltrha ./artifacts/node
- name: build panel - name: push panel
image: plugins/docker image: plugins/docker
settings: settings:
# build_args: registry: git.opplens.se
# - BUILD_APP=scraper repo: git.opplens.se/mazyar/tm-panel
username: cicd username:
from_secret: registry_username
password: password:
from_secret: REGISTRY_PASSWORD from_secret: registry_password
# dockerfile: Dockerfile-drone
repo: artifactory.ravanertebat.ir/tm/admin_panel - name: deploy-uat
registry: artifactory.ravanertebat.ir image: appleboy/drone-ssh
settings:
host: 10.0.0.3
username: mazyar
port: 22
key:
from_secret: ssh_deploy_key
envs:
- DRONE_BUILD_NUMBER
- DRONE_BRANCH
script:
- set -e
- cd /data/tm-uat
- NEW_TAG="${DRONE_BUILD_NUMBER}-${DRONE_BRANCH}"
- echo "Deploying panel tag $NEW_TAG"
- sed -i "s/^PANEL_IMG_TAG=.*/PANEL_IMG_TAG=$NEW_TAG/" .env
- docker compose pull panel
- docker compose up -d panel
- docker image prune -f
when:
branch:
- dev
node:
tag: aecde-docker-runner
trigger: trigger:
branch: branch:
- main - main
- develop - uat
- dev
event: event:
- custom - push
+1 -1
View File
@@ -1,4 +1,4 @@
NEXT_PUBLIC_APP_VERSION=2.3.1 NEXT_PUBLIC_APP_VERSION=2.3.3
NEXT_PUBLIC_TOAST_AUTO_CLOSE_DURATION=2500 NEXT_PUBLIC_TOAST_AUTO_CLOSE_DURATION=2500
NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0 NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com
+16
View File
@@ -0,0 +1,16 @@
# Agent Instructions
## Dual Origin Commit and Push
When the user asks to "commit and push" (or equivalent), always execute both targets unless the user explicitly says otherwise:
- Primary origin push:
- `git push origin dev`
- Old origin push:
- `git push https://repo.ravanertebat.com/TM/tm_panel.git dev:develop`
Behavior requirements:
- If there are uncommitted changes, create a commit first and then push to both targets.
- If there is nothing to commit, still push the current `dev` branch to both targets.
- Do not ask for confirmation for the second push when the user request is "commit and push".
+235
View File
@@ -0,0 +1,235 @@
/// <reference types="cypress" />
const apiResponse = (data: Record<string, unknown>) => ({
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) => {
const days = Number(req.query.days);
expect(req.query.closing_window).to.eq("168");
req.alias = days === 30 ? "summary30Days" : "summary14Days";
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",
days,
scraped_ted: [
{ date: "2026-06-08", count: days === 30 ? 12 : 5 },
{ date: "2026-06-09", count: days === 30 ? 18 : 8 },
],
generated_at: now,
}),
});
});
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_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,
scraped_ted_notices: days === 30 ? 2468 : 1234,
},
}),
});
});
};
describe("tender dashboard", () => {
beforeEach(() => {
cy.setAuthCookies();
stubDashboardRequests();
});
it("renders the new dashboard sections with API data and detail links", () => {
visitDashboard();
cy.wait([
"@summary14Days",
"@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(["@summary14Days", "@statistics14Days"]);
cy.contains("button", "14d").should("have.class", "bg-primary");
cy.contains("button", "30d").click();
cy.wait("@summary30Days")
.its("request.query.days")
.should("eq", "30");
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");
});
});
@@ -0,0 +1,166 @@
/// <reference types="cypress" />
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");
});
});
@@ -0,0 +1,305 @@
/// <reference types="cypress" />
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 notificationHistoryPattern = /\/api\/proxy\/notifications(?:\?|$)/;
const makeNotification = (
overrides: Partial<NotificationRecord> = {},
): 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 = (times = 1) => {
cy.intercept(
{
method: "GET",
url: "**/api/proxy/notifications/my**",
times,
},
{
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", notificationHistoryPattern, (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("exist");
});
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", notificationHistoryPattern, (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("filters notification history by user when a user is selected", () => {
const currentUserNotification = makeNotification({
id: "notification-current-user",
title: "Current user notification",
});
const selectedUserNotification = makeNotification({
id: "notification-selected-user",
title: "Selected user notification",
recipient: { full_name: "Notification Admin" },
});
cy.intercept("GET", notificationHistoryPattern, {
statusCode: 200,
body: historyResponse([currentUserNotification]),
}).as("currentUserNotifications");
cy.intercept("GET", "**/api/proxy/users**", {
statusCode: 200,
body: {
success: true,
message: "ok",
data: {
users: [
{
id: "64f9b24d2f1b2f0012345678",
full_name: "Notification Admin",
username: "notification-admin",
email: "notification-admin@example.com",
role: "admin",
status: "active",
},
],
},
meta: {
offset: 0,
limit: 20,
total: 1,
page: 1,
pages: 1,
has_more: false,
},
},
}).as("users");
cy.intercept("GET", notificationHistoryPattern, (req) => {
expect(req.query.user_id).to.eq("64f9b24d2f1b2f0012345678");
req.reply({
statusCode: 200,
body: historyResponse([selectedUserNotification]),
});
}).as("selectedUserNotifications");
cy.visit("/notification-history");
cy.wait("@currentUserNotifications");
cy.contains(currentUserNotification.title).should("be.visible");
cy.getByCy("notification-history-filter-panel-toggle").click();
cy.getByCy("notification-history-filter-user-select")
.siblings("button")
.click();
cy.getByCy("notification-history-filter-user-select-search").type(
"Notification Admin",
);
cy.wait("@users");
cy.getByCy("notification-history-filter-user-select-option")
.filter('[data-value="64f9b24d2f1b2f0012345678"]')
.click();
cy.getByCy("notification-history-filter-submit-button").click();
cy.wait("@selectedUserNotifications");
cy.location("search").should(
"include",
"user_id=64f9b24d2f1b2f0012345678",
);
cy.contains(selectedUserNotification.title).should("be.visible");
cy.contains(currentUserNotification.title).should("not.exist");
});
it("shows the current user filter when notification history is opened for the current user", () => {
const notification = makeNotification({
id: "notification-current-user-filter",
title: "Current user filtered notification",
});
cy.intercept("GET", notificationHistoryPattern, (req) => {
expect(req.query.user_id).to.eq("507f1f77bcf86cd799439011");
req.reply({
statusCode: 200,
body: historyResponse([notification]),
});
}).as("currentUserFilteredNotifications");
cy.visit("/notification-history?user_id=507f1f77bcf86cd799439011");
cy.wait("@currentUserFilteredNotifications");
cy.getByCy("notification-history-filter-panel-toggle").should(
"contain.text",
"1 active",
);
cy.getByCy("notification-history-filter-panel-toggle").click();
cy.getByCy("notification-history-filter-user-select")
.siblings("button")
.should("contain.text", "E2E Admin One");
cy.contains(notification.title).should("be.visible");
});
it("shows loading and empty states, and opens the create page", () => {
cy.intercept("GET", notificationHistoryPattern, (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");
});
});
@@ -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).
+1
View File
@@ -46,6 +46,7 @@
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"react-tooltip": "^5.29.1", "react-tooltip": "^5.29.1",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"uuid": "^14.0.1",
"zod": "^4.1.5" "zod": "^4.1.5"
}, },
"devDependencies": { "devDependencies": {
+9
View File
@@ -104,6 +104,9 @@ importers:
tailwind-merge: tailwind-merge:
specifier: ^2.6.0 specifier: ^2.6.0
version: 2.6.1 version: 2.6.1
uuid:
specifier: ^14.0.1
version: 14.0.1
zod: zod:
specifier: ^4.1.5 specifier: ^4.1.5
version: 4.3.6 version: 4.3.6
@@ -2977,6 +2980,10 @@ packages:
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@14.0.1:
resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==}
hasBin: true
uuid@8.3.2: uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true hasBin: true
@@ -6218,6 +6225,8 @@ snapshots:
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
uuid@14.0.1: {}
uuid@8.3.2: {} uuid@8.3.2: {}
verror@1.10.0: verror@1.10.0:
@@ -5,6 +5,7 @@ import DocumentItem from "@/components/forms/companies/documents/DocumentItem";
import { ShowcaseSection } from "@/components/Layouts/showcase-section"; import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import Status from "@/components/ui/Status"; import Status from "@/components/ui/Status";
import { useCompanyDetails } from "@/hooks/queries"; import { useCompanyDetails } from "@/hooks/queries";
import type { ICompanyLink } from "@/lib/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BreadcrumbItem } from "@/types/shared"; import { BreadcrumbItem } from "@/types/shared";
import { formatPhoneNumber, unixToDate } from "@/utils/shared"; import { formatPhoneNumber, unixToDate } from "@/utils/shared";
@@ -76,6 +77,9 @@ const CompanyDetailsPage = ({ params }: IProps) => {
const documentFileIds: string[] = Array.isArray(company.document_file_ids) const documentFileIds: string[] = Array.isArray(company.document_file_ids)
? company.document_file_ids ? company.document_file_ids
: []; : [];
const companyLinks: ICompanyLink[] = Array.isArray(company.links)
? company.links
: [];
return ( return (
<> <>
@@ -145,12 +149,18 @@ const CompanyDetailsPage = ({ params }: IProps) => {
{`${company.currency || "—"} ${getSymbolFromCurrency(company.currency ?? "USD")}`} {`${company.currency || "—"} ${getSymbolFromCurrency(company.currency ?? "USD")}`}
</span> </span>
</ShowcaseSection> </ShowcaseSection>
<ShowcaseSection title="Annual revenue" {...SHOWCASE_CENTERED_BODY}> <ShowcaseSection
title="Annual revenue"
{...SHOWCASE_CENTERED_BODY}
>
<span className="font-medium text-dark dark:text-white"> <span className="font-medium text-dark dark:text-white">
{company.annual_revenue || "—"} {company.annual_revenue || "—"}
</span> </span>
</ShowcaseSection> </ShowcaseSection>
<ShowcaseSection title="Employee count" {...SHOWCASE_CENTERED_BODY}> <ShowcaseSection
title="Employee count"
{...SHOWCASE_CENTERED_BODY}
>
<span className="font-medium text-dark dark:text-white"> <span className="font-medium text-dark dark:text-white">
{company.employee_count || "—"} {company.employee_count || "—"}
</span> </span>
@@ -230,6 +240,35 @@ const CompanyDetailsPage = ({ params }: IProps) => {
</div> </div>
</div> </div>
)} )}
{companyLinks.length > 0 && (
<div>
<SectionHeading>Links</SectionHeading>
<div className="grid gap-3 sm:grid-cols-2">
{companyLinks.map((companyLink, index) => (
<Link
key={`${companyLink.url}-${index}`}
href={companyLink.url}
target="_blank"
rel="noopener noreferrer"
className="shadow-theme-xs flex min-w-0 items-center justify-between gap-3 rounded-xl border border-stroke/70 bg-white/80 px-4 py-3 text-primary transition hover:border-primary/50 hover:bg-primary/5 dark:border-dark-3 dark:bg-dark-2/70 dark:hover:border-primary/50"
>
<span className="min-w-0">
<span className="block truncate font-medium text-dark dark:text-white">
{companyLink.title || companyLink.url}
</span>
{companyLink.title ? (
<span className="block truncate text-xs text-dark-5 dark:text-dark-6">
{companyLink.url}
</span>
) : null}
</span>
<ExternalLinkIcon />
</Link>
))}
</div>
</div>
)}
</div> </div>
</ShowcaseSection> </ShowcaseSection>
</> </>
@@ -28,19 +28,25 @@ export function ClosingSoonCard({ tenders, isLoading }: Props) {
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => { useLayoutEffect(() => {
const root = rootRef.current;
if (!root) return;
const reduced = const reduced =
typeof window !== "undefined" && typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches; window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const ctx = gsap.context(() => { const ctx = gsap.context(() => {
if (reduced) return; if (reduced) return;
gsap.from(rootRef.current, { gsap.from(root, {
opacity: 0, opacity: 0,
y: 24, y: 24,
duration: 0.6, duration: 0.6,
ease: "power3.out", ease: "power3.out",
delay: 0.3, delay: 0.3,
}); });
gsap.from("[data-closing-row]", {
const rows = root.querySelectorAll("[data-closing-row]");
if (rows.length > 0) {
gsap.from(rows, {
x: 24, x: 24,
opacity: 0, opacity: 0,
duration: 0.45, duration: 0.45,
@@ -48,14 +54,15 @@ export function ClosingSoonCard({ tenders, isLoading }: Props) {
delay: 0.55, delay: 0.55,
ease: "power2.out", ease: "power2.out",
}); });
}, rootRef); }
}, root);
return () => ctx.revert(); return () => ctx.revert();
}, [tenders.length, isLoading]); }, [tenders.length, isLoading]);
return ( return (
<div <div
ref={rootRef} ref={rootRef}
className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card" className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:shadow-card dark:ring-stroke-dark"
> >
<div className="flex items-center justify-between border-b border-stroke px-5 py-4 dark:border-stroke-dark"> <div className="flex items-center justify-between border-b border-stroke px-5 py-4 dark:border-stroke-dark">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -66,7 +73,9 @@ export function ClosingSoonCard({ tenders, isLoading }: Props) {
<h2 className="text-base font-bold text-dark dark:text-white"> <h2 className="text-base font-bold text-dark dark:text-white">
Closing Soon Closing Soon
</h2> </h2>
<p className="text-xs text-dark-6">Tenders with looming deadlines</p> <p className="text-xs text-dark-6">
Tenders with looming deadlines
</p>
</div> </div>
</div> </div>
<Link <Link
@@ -80,7 +89,10 @@ export function ClosingSoonCard({ tenders, isLoading }: Props) {
<ul className="flex-1 divide-y divide-stroke dark:divide-stroke-dark"> <ul className="flex-1 divide-y divide-stroke dark:divide-stroke-dark">
{isLoading {isLoading
? Array.from({ length: 5 }).map((_, i) => ( ? Array.from({ length: 5 }).map((_, i) => (
<li key={i} className="flex animate-pulse items-center gap-3 px-5 py-3.5"> <li
key={i}
className="flex animate-pulse items-center gap-3 px-5 py-3.5"
>
<div className="size-10 rounded-lg bg-gray-2 dark:bg-dark-3" /> <div className="size-10 rounded-lg bg-gray-2 dark:bg-dark-3" />
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<div className="h-3 w-3/4 rounded bg-gray-2 dark:bg-dark-3" /> <div className="h-3 w-3/4 rounded bg-gray-2 dark:bg-dark-3" />
+11 -7
View File
@@ -4,6 +4,7 @@ import { ClosingSoonCard } from "./closing-soon";
import { CountryDistribution } from "./country-distribution"; import { CountryDistribution } from "./country-distribution";
import { DashboardHero } from "./hero"; import { DashboardHero } from "./hero";
import { NoticeTypeBreakdown } from "./notice-types"; import { NoticeTypeBreakdown } from "./notice-types";
import { ScrapePortals } from "./portals";
import { RecentTenders } from "./recent-tenders"; import { RecentTenders } from "./recent-tenders";
import { StatisticsSection } from "./statistics-section"; import { StatisticsSection } from "./statistics-section";
import { TenderStatCards } from "./stat-cards"; import { TenderStatCards } from "./stat-cards";
@@ -19,11 +20,11 @@ export function TenderDashboard() {
totalValue, totalValue,
valueCurrency, valueCurrency,
countries, countries,
noticeTypes,
trend, trend,
recent, recent,
recentIsLoading, recentIsLoading,
closingSoonList, portals,
portalsIsLoading,
} = useDashboardData(); } = useDashboardData();
return ( return (
@@ -53,19 +54,22 @@ export function TenderDashboard() {
<CountryDistribution countries={countries} isLoading={isPending} /> <CountryDistribution countries={countries} isLoading={isPending} />
</div> </div>
<div className="col-span-12 xl:col-span-5"> {/* <div className="col-span-12 xl:col-span-5">
<NoticeTypeBreakdown <NoticeTypeBreakdown
noticeTypes={noticeTypes} noticeTypes={noticeTypes}
isLoading={isPending} isLoading={isPending}
/> />
</div> </div> */}
<div className="col-span-12 xl:col-span-7"> {/* <div className="col-span-12 xl:col-span-7">
<ClosingSoonCard tenders={closingSoonList} isLoading={isPending} /> <ClosingSoonCard tenders={closingSoonList} isLoading={isPending} />
</div> </div> */}
<div className="col-span-12"> <div className="col-span-12 xl:col-span-8">
<RecentTenders tenders={recent} isLoading={recentIsLoading} /> <RecentTenders tenders={recent} isLoading={recentIsLoading} />
</div> </div>
<div className="col-span-12 xl:col-span-4">
<ScrapePortals portals={portals} isLoading={portalsIsLoading} />
</div>
</div> </div>
<StatisticsSection /> <StatisticsSection />
@@ -0,0 +1,113 @@
"use client";
import { TPortal } from "@/lib/api";
import { truncateString } from "@/utils/shared";
import gsap from "gsap";
import { useLayoutEffect, useRef } from "react";
import { GlobeIcon } from "./icons";
type Props = {
portals: TPortal[];
isLoading: boolean;
};
export function ScrapePortals({ portals, isLoading }: Props) {
const rootRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const root = rootRef.current;
if (!root) return;
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const ctx = gsap.context(() => {
if (reduced) return;
gsap.from(root, {
opacity: 0,
y: 24,
duration: 0.6,
ease: "power3.out",
delay: 0.35,
});
const rows = root.querySelectorAll("[data-portal-row]");
if (rows.length > 0) {
gsap.from(rows, {
x: 24,
opacity: 0,
duration: 0.5,
stagger: 0.06,
delay: 0.55,
ease: "power2.out",
});
}
}, root);
return () => ctx.revert();
}, [portals.length, isLoading]);
return (
<div
ref={rootRef}
className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:shadow-card dark:ring-stroke-dark"
>
<div className="flex items-center justify-between border-b border-stroke px-5 py-4 dark:border-stroke-dark">
<div className="flex items-center gap-2">
<span className="inline-flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<GlobeIcon className="size-5" />
</span>
<div>
<h2 className="text-base font-bold text-dark dark:text-white">
Scrape Portals
</h2>
<p className="text-xs text-dark-6">
Tender sources monitored by the pipeline
</p>
</div>
</div>
{!isLoading && portals.length > 0 && (
<span className="rounded-full bg-gray-2 px-2.5 py-1 text-xs font-semibold tabular-nums text-dark-5 dark:bg-dark-3 dark:text-dark-6">
{portals.length}
</span>
)}
</div>
<ul className="max-h-[420px] flex-1 divide-y divide-stroke overflow-y-auto dark:divide-stroke-dark">
{isLoading
? Array.from({ length: 5 }).map((_, i) => (
<li
key={i}
className="flex animate-pulse items-center gap-3 px-5 py-3.5"
>
<div className="size-8 rounded-lg bg-gray-2 dark:bg-dark-3" />
<div className="h-3 flex-1 rounded bg-gray-2 dark:bg-dark-3" />
</li>
))
: portals.map((portal, index) => (
<li key={`${portal}-${index}`} data-portal-row>
<p
rel="noopener noreferrer"
className="group flex items-center gap-3 px-5 py-3.5 transition-colors hover:bg-gray-2 dark:hover:bg-dark-2"
>
<span className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-bold text-primary">
{index + 1}
</span>
<span className="min-w-0 flex-1 text-sm font-medium text-dark transition-colors group-hover:text-primary dark:text-white">
{portal.length > 60
? truncateString(portal, 28, 28)
: portal}
</span>
</p>
</li>
))}
{!isLoading && portals.length === 0 && (
<li className="px-5 py-10 text-center text-sm text-dark-6">
No portals configured.
</li>
)}
</ul>
</div>
);
}
@@ -45,19 +45,25 @@ export function RecentTenders({ tenders, isLoading }: Props) {
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => { useLayoutEffect(() => {
const root = rootRef.current;
if (!root) return;
const reduced = const reduced =
typeof window !== "undefined" && typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches; window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const ctx = gsap.context(() => { const ctx = gsap.context(() => {
if (reduced) return; if (reduced) return;
gsap.from(rootRef.current, { gsap.from(root, {
opacity: 0, opacity: 0,
y: 24, y: 24,
duration: 0.6, duration: 0.6,
ease: "power3.out", ease: "power3.out",
delay: 0.35, delay: 0.35,
}); });
gsap.from("[data-recent-row]", {
const rows = root.querySelectorAll("[data-recent-row]");
if (rows.length > 0) {
gsap.from(rows, {
x: -24, x: -24,
opacity: 0, opacity: 0,
duration: 0.5, duration: 0.5,
@@ -65,14 +71,15 @@ export function RecentTenders({ tenders, isLoading }: Props) {
delay: 0.55, delay: 0.55,
ease: "power2.out", ease: "power2.out",
}); });
}, rootRef); }
}, root);
return () => ctx.revert(); return () => ctx.revert();
}, [tenders.length, isLoading]); }, [tenders.length, isLoading]);
return ( return (
<div <div
ref={rootRef} ref={rootRef}
className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card" className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:shadow-card dark:ring-stroke-dark"
> >
<div className="flex items-center justify-between border-b border-stroke px-5 py-4 dark:border-stroke-dark"> <div className="flex items-center justify-between border-b border-stroke px-5 py-4 dark:border-stroke-dark">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -8,17 +8,22 @@ import {
useDashboardTrendQuery, useDashboardTrendQuery,
useTendersQuery, useTendersQuery,
} from "@/hooks/queries"; } from "@/hooks/queries";
import { useGetPortalsQuery } from "@/hooks/queries/usePortalQueries";
import { useMemo } from "react"; import { useMemo } from "react";
import { toLabeledPoints } from "./to-labeled-points"; import { toLabeledPoints } from "./to-labeled-points";
export type DashboardMetrics = ReturnType<typeof useDashboardData>; export type DashboardMetrics = ReturnType<typeof useDashboardData>;
export function useDashboardData() { export function useDashboardData() {
const summaryQuery = useDashboardSummaryQuery(); const summaryQuery = useDashboardSummaryQuery({
closing_window: 168,
days: 14,
});
const trendQuery = useDashboardTrendQuery({ days: 14, metric: "created" }); const trendQuery = useDashboardTrendQuery({ days: 14, metric: "created" });
const countriesQuery = useDashboardCountriesQuery({ limit: 6 }); const countriesQuery = useDashboardCountriesQuery({ limit: 6 });
const noticeTypesQuery = useDashboardNoticeTypesQuery(); const noticeTypesQuery = useDashboardNoticeTypesQuery();
const closingSoonQuery = useDashboardClosingSoonQuery({ limit: 5 }); const closingSoonQuery = useDashboardClosingSoonQuery({ limit: 5 });
const portalsQuery = useGetPortalsQuery();
const { const {
data: recentData, data: recentData,
@@ -87,6 +92,8 @@ export function useDashboardData() {
const recent = recentData?.data?.tenders ?? []; const recent = recentData?.data?.tenders ?? [];
const recentIsLoading = recentPending && recent.length === 0; const recentIsLoading = recentPending && recent.length === 0;
const portals = portalsQuery.data?.data ?? [];
const portalsIsLoading = portalsQuery.isPending && portals.length === 0;
return { return {
isPending, isPending,
@@ -104,5 +111,7 @@ export function useDashboardData() {
recent, recent,
recentIsLoading, recentIsLoading,
closingSoonList, closingSoonList,
portals,
portalsIsLoading,
}; };
} }
@@ -1,6 +1,9 @@
"use client"; "use client";
import { useDashboardStatisticsQuery } from "@/hooks/queries"; import {
useDashboardStatisticsQuery,
useDashboardSummaryQuery,
} from "@/hooks/queries";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { toLabeledPoints } from "./to-labeled-points"; import { toLabeledPoints } from "./to-labeled-points";
@@ -11,14 +14,16 @@ export type StatisticsDays = (typeof STATISTICS_DAY_OPTIONS)[number];
export function useStatistics() { export function useStatistics() {
const [days, setDays] = useState<StatisticsDays>(14); const [days, setDays] = useState<StatisticsDays>(14);
const summaryQuery = useDashboardSummaryQuery({ closing_window: 168, days });
const query = useDashboardStatisticsQuery({ days }); const query = useDashboardStatisticsQuery({ days });
const summary = summaryQuery.data?.data;
const stats = query.data?.data; const stats = query.data?.data;
const daily = stats?.daily; const daily = stats?.daily;
const scrapedTed = useMemo( const scrapedTed = useMemo(
() => toLabeledPoints(daily?.scraped_ted ?? []), () => toLabeledPoints(summary?.scraped_ted ?? []),
[daily], [summary],
); );
const scrapedDocuments = useMemo( const scrapedDocuments = useMemo(
() => toLabeledPoints(daily?.scraped_documents ?? []), () => toLabeledPoints(daily?.scraped_documents ?? []),
@@ -32,9 +37,12 @@ export function useStatistics() {
return { return {
days, days,
setDays, setDays,
isLoading: query.isPending, isLoading: query.isPending || summaryQuery.isPending,
isError: query.isError, isError: query.isError || summaryQuery.isError,
refetch: query.refetch, refetch: () => {
summaryQuery.refetch();
query.refetch();
},
scrapedTed, scrapedTed,
scrapedDocuments, scrapedDocuments,
translatedNotices, translatedNotices,
+7 -1
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import InputGroup from "@/components/FormElements/InputGroup"; import InputGroup from "@/components/FormElements/InputGroup";
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area"; import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
import { createPhoneFieldRules } from "@/components/FormElements/PhoneNumberInput";
import { ShowcaseSection } from "@/components/Layouts/showcase-section"; import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import { FormErrorMessages } from "@/constants/Texts"; import { FormErrorMessages } from "@/constants/Texts";
import { forwardRef } from "react"; import { forwardRef } from "react";
@@ -46,11 +47,16 @@ const FooterStep = forwardRef<unknown, FooterStepProps>(
</p> </p>
)} )}
<InputGroup <InputGroup
{...register("phone")} {...register("phone", createPhoneFieldRules())}
label="Phone Number" label="Phone Number"
name="phone" name="phone"
type="text" type="text"
/> />
{errors.phone && (
<p className="mt-1 text-sm text-red-500">
{errors.phone.message as string}
</p>
)}
<InputGroup <InputGroup
{...register("location")} {...register("location")}
label="Location" label="Location"
@@ -103,6 +103,10 @@ const HeroStep = forwardRef<unknown, HeroStepProps>(({ initialData, onValidityCh
value: 200, value: 200,
message: FormErrorMessages.maxLength(200), message: FormErrorMessages.maxLength(200),
}, },
pattern: {
value: REGEX.URL,
message: FormErrorMessages.invalidPattern,
},
})} })}
label="Button Link" label="Button Link"
name="button_link" name="button_link"
+1
View File
@@ -20,6 +20,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
<SidebarProvider> <SidebarProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ToastContainer <ToastContainer
className="!z-[1000001]"
position="top-right" position="top-right"
autoClose={2500} autoClose={2500}
theme={"colored"} theme={"colored"}
+4 -6
View File
@@ -29,9 +29,7 @@ interface IProps {
// Backend has shipped the documents payload as a bare array, an `ApiResponse` // Backend has shipped the documents payload as a bare array, an `ApiResponse`
// wrapper, and a `{ data: { documents: [...] } }` wrapper at different times. // wrapper, and a `{ data: { documents: [...] } }` wrapper at different times.
// Normalize so a real list is never dropped because of a wrapper mismatch. // Normalize so a real list is never dropped because of a wrapper mismatch.
function extractTenderDocuments( function extractTenderDocuments(payload: unknown): TTenderDocumentsResponse[] {
payload: unknown,
): TTenderDocumentsResponse[] {
if (!payload) return []; if (!payload) return [];
if (Array.isArray(payload)) return payload as TTenderDocumentsResponse[]; if (Array.isArray(payload)) return payload as TTenderDocumentsResponse[];
@@ -58,12 +56,12 @@ const TenderDetails = ({ params }: IProps) => {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const selectedLanguage = searchParams.get("lang") || "en"; const selectedLanguage = searchParams.get("lang") ?? "";
const { mutate: translateTender, isPending: isTranslating } = const { mutate: translateTender, isPending: isTranslating } =
useTranslateTenderMutation(); useTranslateTenderMutation();
const { data, isLoading, isError, isFetching } = useTenderDetailQuery( const { data, isLoading, isError, isFetching } = useTenderDetailQuery(
details, details,
{ language: selectedLanguage }, selectedLanguage ? { language: selectedLanguage } : undefined,
); );
const breadcrumbItems = [ const breadcrumbItems = [
@@ -107,7 +105,7 @@ const TenderDetails = ({ params }: IProps) => {
const nextLanguage = event.target.value; const nextLanguage = event.target.value;
if (!nextLanguage) return; if (!nextLanguage) return;
const prevLanguage = searchParams.get("lang") || "en"; const prevLanguage = searchParams.get("lang") ?? "";
if (nextLanguage === prevLanguage) return; if (nextLanguage === prevLanguage) return;
userPendingLangRef.current = nextLanguage; userPendingLangRef.current = nextLanguage;
@@ -11,6 +11,7 @@ import {
type UseFormRegister, type UseFormRegister,
type UseFormRegisterReturn, type UseFormRegisterReturn,
} from "react-hook-form"; } from "react-hook-form";
import { get } from "react-hook-form";
type InputGroupProps<T extends FieldValues> = { type InputGroupProps<T extends FieldValues> = {
name: Path<T>; name: Path<T>;
@@ -58,7 +59,7 @@ const InputGroup = <T extends FieldValues>({
}: InputGroupProps<T>): React.ReactElement => { }: InputGroupProps<T>): React.ReactElement => {
const id = useId(); const id = useId();
const error = errors && errors[name]; const error = errors && get(errors, name);
return ( return (
<div className={className}> <div className={className}>
+16 -10
View File
@@ -153,7 +153,12 @@ type PropsType<T extends FieldValues> = {
) & ) &
Partial<UseFormRegisterReturn>; Partial<UseFormRegisterReturn>;
export function Select<T extends FieldValues>({ export function Select<T extends FieldValues>(selectProps: PropsType<T>) {
const hasControlledValue = Object.prototype.hasOwnProperty.call(
selectProps,
"value",
);
const {
items, items,
label, label,
defaultValue, defaultValue,
@@ -184,7 +189,7 @@ export function Select<T extends FieldValues>({
isLoadingMore = false, isLoadingMore = false,
register: _register, register: _register,
...props ...props
}: PropsType<T>) { } = selectProps;
void _register; void _register;
/** Parent-controlled list: search + pagination handled outside, `items` consumed as-is. */ /** Parent-controlled list: search + pagination handled outside, `items` consumed as-is. */
@@ -223,11 +228,12 @@ export function Select<T extends FieldValues>({
const error = errors && errors[name]; const error = errors && errors[name];
useEffect(() => { useEffect(() => {
if (controlledValue !== undefined) { if (hasControlledValue) {
setValue(controlledValue); const nextValue = controlledValue ?? "";
setIsOptionSelected(!!controlledValue); setValue(nextValue);
setIsOptionSelected(!!nextValue);
} }
}, [controlledValue]); }, [controlledValue, hasControlledValue]);
const { const {
isServerMode, isServerMode,
@@ -333,7 +339,7 @@ export function Select<T extends FieldValues>({
}, [open, showSearch]); }, [open, showSearch]);
const emitChange = (next: string) => { const emitChange = (next: string) => {
if (controlledValue === undefined) { if (!hasControlledValue) {
setValue(next); setValue(next);
} }
setIsOptionSelected(!!next); setIsOptionSelected(!!next);
@@ -345,7 +351,7 @@ export function Select<T extends FieldValues>({
}; };
const handleClear = () => { const handleClear = () => {
if (controlledValue === undefined) { if (!hasControlledValue) {
setValue(""); setValue("");
} }
setIsOptionSelected(false); setIsOptionSelected(false);
@@ -498,7 +504,7 @@ export function Select<T extends FieldValues>({
data-cy={dataCy} data-cy={dataCy}
className="sr-only" className="sr-only"
onChange={(e) => { onChange={(e) => {
if (controlledValue === undefined) { if (!hasControlledValue) {
setValue(e.target.value); setValue(e.target.value);
} }
setIsOptionSelected(!!e.target.value); setIsOptionSelected(!!e.target.value);
@@ -612,7 +618,7 @@ export function Select<T extends FieldValues>({
{dropdown} {dropdown}
{error && ( {error && (
<p className="text-red mt-1.5 text-sm font-medium"> <p className="mt-1.5 text-sm font-medium text-error">
{error.message as string} {error.message as string}
</p> </p>
)} )}
@@ -5,29 +5,54 @@ import {
DropdownContent, DropdownContent,
DropdownTrigger, DropdownTrigger,
} from "@/components/ui/dropdown"; } from "@/components/ui/dropdown";
import { useUser } from "@/contexts";
import Modal from "@/components/ui/modal"; import Modal from "@/components/ui/modal";
import Status from "@/components/ui/Status"; import Status from "@/components/ui/Status";
import { useMarkNotificationAsSeen } from "@/hooks/queries/useNotificationQueries"; import {
useGetMyNotifications,
useMarkNotificationAsSeen,
} from "@/hooks/queries/useNotificationQueries";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { TNotificationDetailsResponse } from "@/lib/api/types/NotificationHistory"; import { TNotificationDetailsResponse } from "@/lib/api/types/NotificationHistory";
import { cn, sanitizeHtmlWithStyles } from "@/lib/utils"; import { cn, sanitizeHtmlWithStyles } from "@/lib/utils";
import { capitalize, unixToDate } from "@/utils/shared"; import { capitalize, unixToDate } from "@/utils/shared";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { BellIcon } from "./icons"; import { BellIcon } from "./icons";
import NotificationList from "./notification-list"; import NotificationList from "./notification-list";
export function Notification() { export function Notification() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isDotVisible, setIsDotVisible] = useState(true); const [isBadgeVisible, setIsBadgeVisible] = useState(true);
const [unreadNotificationCount, setUnreadNotificationCount] = const [unreadNotificationCount, setUnreadNotificationCount] =
useState<number>(0); useState<number>(0);
const previousUnreadCountRef = useRef(0);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [currentNotification, setCurrentNotification] = const [currentNotification, setCurrentNotification] =
useState<TNotificationDetailsResponse | null>(null); useState<TNotificationDetailsResponse | null>(null);
const { mutate } = useMarkNotificationAsSeen(); const { mutate } = useMarkNotificationAsSeen();
const { data: unreadNotifications, isLoading: isLoadingNotifications } =
useGetMyNotifications({ seen: false });
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { user } = useUser();
useEffect(() => {
const count = unreadNotifications?.data.length ?? 0;
setUnreadNotificationCount(count);
if (count > previousUnreadCountRef.current) {
setIsBadgeVisible(true);
}
previousUnreadCountRef.current = count;
}, [unreadNotifications]);
const notificationHistoryHref = useMemo(() => {
if (!user?.id) return "/notification-history";
return `/notification-history?user_id=${encodeURIComponent(user.id)}`;
}, [user?.id]);
const handleNotificationClick = ( const handleNotificationClick = (
notification: TNotificationDetailsResponse, notification: TNotificationDetailsResponse,
@@ -44,7 +69,7 @@ export function Notification() {
isOpen={isOpen} isOpen={isOpen}
setIsOpen={(open) => { setIsOpen={(open) => {
setIsOpen(open); setIsOpen(open);
if (setIsDotVisible) setIsDotVisible(false); if (open) setIsBadgeVisible(false);
}} }}
> >
<DropdownTrigger <DropdownTrigger
@@ -53,7 +78,7 @@ export function Notification() {
> >
<span className="relative"> <span className="relative">
<BellIcon /> <BellIcon />
{isDotVisible && unreadNotificationCount > 0 && ( {isBadgeVisible && unreadNotificationCount > 0 && (
<span <span
className={cn( className={cn(
"absolute -right-1.5 -top-1.5 z-1 grid min-w-[18px] place-items-center rounded-full bg-gradient-to-br from-rose-500 to-red-600 px-1 text-[10px] font-bold leading-none text-white shadow-[0_2px_8px_rgba(239,68,68,0.55)] ring-2 ring-white dark:ring-dark-2", "absolute -right-1.5 -top-1.5 z-1 grid min-w-[18px] place-items-center rounded-full bg-gradient-to-br from-rose-500 to-red-600 px-1 text-[10px] font-bold leading-none text-white shadow-[0_2px_8px_rgba(239,68,68,0.55)] ring-2 ring-white dark:ring-dark-2",
@@ -81,12 +106,13 @@ export function Notification() {
</div> </div>
<NotificationList <NotificationList
setUnreadNotificationCount={setUnreadNotificationCount} notifications={unreadNotifications?.data}
isLoading={isLoadingNotifications}
onNotificationClick={handleNotificationClick} onNotificationClick={handleNotificationClick}
/> />
<Link <Link
href="/notification-history" href={notificationHistoryHref}
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="block rounded-xl border border-primary/40 bg-white/70 p-2 text-center text-sm font-semibold tracking-wide text-primary outline-none transition-colors hover:bg-primary/10 focus:bg-primary/10 focus:text-primary focus-visible:border-primary dark:border-dark-3 dark:bg-dark-2/70 dark:text-dark-6 dark:hover:border-dark-5 dark:hover:bg-dark-3 dark:hover:text-dark-7 dark:focus-visible:border-dark-5 dark:focus-visible:bg-dark-3 dark:focus-visible:text-dark-7" className="block rounded-xl border border-primary/40 bg-white/70 p-2 text-center text-sm font-semibold tracking-wide text-primary outline-none transition-colors hover:bg-primary/10 focus:bg-primary/10 focus:text-primary focus-visible:border-primary dark:border-dark-3 dark:bg-dark-2/70 dark:text-dark-6 dark:hover:border-dark-5 dark:hover:bg-dark-3 dark:hover:text-dark-7 dark:focus-visible:border-dark-5 dark:focus-visible:bg-dark-3 dark:focus-visible:text-dark-7"
> >
@@ -111,7 +137,7 @@ export function Notification() {
<div className="pointer-events-none absolute -bottom-12 -left-6 size-28 rounded-full bg-white/10 blur-md" /> <div className="pointer-events-none absolute -bottom-12 -left-6 size-28 rounded-full bg-white/10 blur-md" />
<div className="relative flex items-start gap-4"> <div className="relative flex items-start gap-4">
<span className="grid size-12 shrink-0 place-items-center rounded-2xl bg-white/20 text-white shadow-theme-sm ring-1 ring-white/30 backdrop-blur-sm"> <span className="shadow-theme-sm grid size-12 shrink-0 place-items-center rounded-2xl bg-white/20 text-white ring-1 ring-white/30 backdrop-blur-sm">
<BellIcon /> <BellIcon />
</span> </span>
@@ -176,7 +202,7 @@ export function Notification() {
<button <button
type="button" type="button"
onClick={() => setIsModalOpen(false)} onClick={() => setIsModalOpen(false)}
className="rounded-xl bg-gradient-to-br from-primary to-blue-700 px-5 py-2 text-sm font-semibold text-white shadow-theme-sm transition-opacity hover:opacity-90" className="shadow-theme-sm rounded-xl bg-gradient-to-br from-primary to-blue-700 px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
> >
Got it Got it
</button> </button>
@@ -2,36 +2,29 @@
import Loading from "@/components/loading"; import Loading from "@/components/loading";
import IsVisible from "@/components/ui/IsVisible"; import IsVisible from "@/components/ui/IsVisible";
import { useGetMyNotifications } from "@/hooks/queries/useNotificationQueries";
import { TNotificationDetailsResponse } from "@/lib/api/types/NotificationHistory"; import { TNotificationDetailsResponse } from "@/lib/api/types/NotificationHistory";
import { truncateString } from "@/utils/shared"; import { truncateString } from "@/utils/shared";
import { Dispatch, SetStateAction, useEffect } from "react";
interface IProps { interface IProps {
setUnreadNotificationCount: Dispatch<SetStateAction<number>>; notifications?: TNotificationDetailsResponse[];
isLoading: boolean;
onNotificationClick: (notification: TNotificationDetailsResponse) => void; onNotificationClick: (notification: TNotificationDetailsResponse) => void;
} }
const NotificationList = ({ const NotificationList = ({
setUnreadNotificationCount, notifications,
isLoading,
onNotificationClick, onNotificationClick,
}: IProps) => { }: IProps) => {
const { data, isLoading } = useGetMyNotifications({ seen: false });
useEffect(() => {
setUnreadNotificationCount(data?.data.length ?? 0);
}, [data, isLoading]);
if (isLoading) if (isLoading)
return ( return <Loading className="min-h-48 flex-none justify-center p-6" />;
<Loading className="min-h-48 flex-none justify-center p-6" />
);
return ( return (
<ul className="mb-3 max-h-[23rem] space-y-1.5 overflow-y-auto"> <ul className="mb-3 max-h-[23rem] space-y-1.5 overflow-y-auto">
<IsVisible condition={data?.data.length === 0}> <IsVisible condition={notifications?.length === 0}>
<h2 className="cursor-default ps-2">No new notification</h2> <h2 className="cursor-default ps-2">No new notification</h2>
</IsVisible> </IsVisible>
{data?.data.map((notification) => ( {notifications?.map((notification) => (
<li <li
role="menuitem" role="menuitem"
key={notification.id} key={notification.id}
@@ -104,7 +104,7 @@ export const useCompanyListPresenter = () => {
name: "", name: "",
email: "", email: "",
phone: "", phone: "",
currency: undefined, currency: "",
employee_count: "", employee_count: "",
}); });
router.push(pathName); router.push(pathName);
@@ -156,17 +156,27 @@ const useCustomerListPresenter = () => {
}; };
const clearAllFilters = useCallback(() => { const clearAllFilters = useCallback(() => {
filterFormReset({ const clearedFilters = {
status: undefined, ...apiDefaultParams,
role: undefined, offset: 0,
type: undefined, limit: 10,
full_name: undefined, status: "",
email: undefined, role: "",
company_id: undefined, type: "",
}); full_name: "",
email: "",
company_id: "",
};
filterFormReset(clearedFilters);
setFilterValue("status", "");
setFilterValue("role", "");
setFilterValue("type", "");
setFilterValue("company_id", "");
setCompanySearch(""); setCompanySearch("");
setParams(clearedFilters);
router.push(pathName); router.push(pathName);
}, [pathName, router, filterFormReset]); }, [pathName, router, filterFormReset, setFilterValue]);
const columns = [ const columns = [
"row", "row",
@@ -11,14 +11,12 @@ interface ChangeStatusFormProps {
onSubmit: (data: TChangeInquiryStatusCredentials) => void; onSubmit: (data: TChangeInquiryStatusCredentials) => void;
onCancel: () => void; onCancel: () => void;
currentStatus: string; currentStatus: string;
} }
const ChangeStatusForm = ({ const ChangeStatusForm = ({
onSubmit, onSubmit,
onCancel, onCancel,
currentStatus, currentStatus,
}: ChangeStatusFormProps) => { }: ChangeStatusFormProps) => {
const { const {
register, register,
@@ -37,10 +35,19 @@ const ChangeStatusForm = ({
value: status, value: status,
})); }));
const handleStatusSubmit = (data: TChangeInquiryStatusCredentials) => {
if (data.status === currentStatus) return;
onSubmit(data);
};
return ( return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(handleStatusSubmit)} className="space-y-4">
<Select <Select
{...register("status", { required: "Status is required" })} {...register("status", {
required: "Status is required",
validate: (value) =>
value !== currentStatus || "Select a different status",
})}
name="status" name="status"
label="Status" label="Status"
items={statusOptions} items={statusOptions}
@@ -4,6 +4,10 @@ import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select"; import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button"; import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel"; import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { useUser } from "@/contexts";
import { apiDefaultParams } from "@/constants/Api_Params";
import { userService } from "@/lib/api/services/user-services";
import { ILabelValue } from "@/types/shared";
import { import {
FieldValues, FieldValues,
UseFormHandleSubmit, UseFormHandleSubmit,
@@ -34,11 +38,38 @@ function countActive(values: Record<string, unknown>): number {
) { ) {
n++; n++;
} }
const userId = values.user_id;
if (
userId !== undefined &&
userId !== null &&
userId !== "" &&
String(userId).trim()
) {
n++;
}
const searchVal = values.search; const searchVal = values.search;
if (typeof searchVal === "string" && searchVal.trim()) n++; if (typeof searchVal === "string" && searchVal.trim()) n++;
return n; return n;
} }
const searchUsers = async (query: string): Promise<ILabelValue[]> => {
const response = await userService.adminsList({
...apiDefaultParams,
search: query,
offset: 0,
limit: 20,
});
return (response.data?.users ?? []).map((user) => ({
value: user.id,
label:
user.full_name ||
user.username ||
user.email ||
user.id,
}));
};
const NotificationHistoryListFilters = ({ const NotificationHistoryListFilters = ({
filterRegister, filterRegister,
isMutating, isMutating,
@@ -48,7 +79,22 @@ const NotificationHistoryListFilters = ({
setValue, setValue,
onClearAll, onClearAll,
}: NotificationHistoryListFiltersProps) => { }: NotificationHistoryListFiltersProps) => {
const { user } = useUser();
const watched = watch(); const watched = watch();
const currentUserOption = useMemo<ILabelValue[]>(() => {
if (!user?.id) return [];
return [
{
value: user.id,
label:
user.full_name ||
user.username ||
user.email ||
user.id,
},
];
}, [user?.email, user?.full_name, user?.id, user?.username]);
const activeCount = useMemo( const activeCount = useMemo(
() => countActive(watched as Record<string, unknown>), () => countActive(watched as Record<string, unknown>),
[watched], [watched],
@@ -68,7 +114,7 @@ const NotificationHistoryListFilters = ({
onSubmit={handleFilterSubmit(search)} onSubmit={handleFilterSubmit(search)}
data-cy="notification-history-filter-form" data-cy="notification-history-filter-form"
> >
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-3">
<Select <Select
{...filterRegister("seen")} {...filterRegister("seen")}
items={[ items={[
@@ -82,6 +128,22 @@ const NotificationHistoryListFilters = ({
value={watch("seen")} value={watch("seen")}
onClear={() => setValue("seen", undefined)} onClear={() => setValue("seen", undefined)}
/> />
<Select
{...filterRegister("user_id")}
items={currentUserOption}
label="User"
name="user_id"
placeholder="Current user"
clearable
searchable
searchMode="server"
searchPlaceholder="Search users"
searchQueryKey={["notification-history-user-filter"]}
searchFn={searchUsers}
value={watch("user_id")}
onClear={() => setValue("user_id", undefined)}
dataCy="notification-history-filter-user-select"
/>
<InputGroup <InputGroup
{...filterRegister("search")} {...filterRegister("search")}
name="search" name="search"
@@ -83,7 +83,7 @@ const useNotificationHistoryTablePresenter = () => {
}; };
const clearAllFilters = useCallback(() => { const clearAllFilters = useCallback(() => {
filterFormReset({ seen: undefined, search: "" }); filterFormReset({ seen: undefined, search: "", user_id: undefined });
router.push(pathName); router.push(pathName);
}, [pathName, router, filterFormReset]); }, [pathName, router, filterFormReset]);
@@ -13,6 +13,7 @@ import { isDateRangeActive } from "@/utils/shared";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
Control, Control,
Controller,
FieldValues, FieldValues,
UseFormHandleSubmit, UseFormHandleSubmit,
UseFormRegister, UseFormRegister,
@@ -214,12 +215,22 @@ const TenderListFilters = ({
subtitle="Restrict to tenders whose documents have been scraped." subtitle="Restrict to tenders whose documents have been scraped."
> >
<div className="max-w-fit rounded-xl border border-stroke/40 bg-white/60 p-4 dark:border-white/[0.06] dark:bg-dark-3/40"> <div className="max-w-fit rounded-xl border border-stroke/40 bg-white/60 p-4 dark:border-white/[0.06] dark:bg-dark-3/40">
<Controller
name="documents_scraped"
control={control}
render={({ field: { value, onChange, name, onBlur, ref } }) => (
<Checkbox <Checkbox
ref={ref}
name={name}
label="Documents scraped" label="Documents scraped"
minimal minimal
withBg withBg
withIcon="check" withIcon="check"
{...filterRegister("documents_scraped")} checked={value === true}
onBlur={onBlur}
onChange={(event) => onChange(event.target.checked)}
/>
)}
/> />
</div> </div>
</CollapsibleFilterSection> </CollapsibleFilterSection>
+33 -12
View File
@@ -5,7 +5,6 @@ import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
import IsVisible from "@/components/ui/IsVisible"; import IsVisible from "@/components/ui/IsVisible";
import ListHeader from "@/components/ui/ListHeader"; import ListHeader from "@/components/ui/ListHeader";
import ListWrapper from "@/components/ui/ListWrapper"; import ListWrapper from "@/components/ui/ListWrapper";
import Pagination from "@/components/ui/pagination";
import Status from "@/components/ui/Status"; import Status from "@/components/ui/Status";
import { import {
Table, Table,
@@ -49,7 +48,8 @@ const TendersTable = () => {
navigateToTenderDetails, navigateToTenderDetails,
navigateToTenderFeedback, navigateToTenderFeedback,
handlePaginationChange, handlePaginationChange,
buildWindowedPageLabel, hasNext,
hasPrevious,
getRowNumber, getRowNumber,
formatDateTimeCell, formatDateTimeCell,
pagination, pagination,
@@ -204,16 +204,37 @@ const TendersTable = () => {
/> />
</div> </div>
<IsVisible condition={shouldShowPagination}> <IsVisible condition={shouldShowPagination}>
<Pagination <nav
key={`pagination-tenders-${absoluteCurrentPage}`} className="mt-2 flex w-full items-center justify-center gap-3 text-sm"
currentPage={pagination.currentPage} aria-label="Tender pagination"
totalPages={pagination.totalPages} >
onPageChange={handlePaginationChange} <button
pageRangeDisplayed={3} type="button"
marginPagesDisplayed={1} disabled={!hasPrevious}
pageLabelBuilder={buildWindowedPageLabel} onClick={() =>
variant="tenders" handlePaginationChange({ selected: absoluteCurrentPage - 1 })
/> }
className="shadow-theme-xs rounded-xl border border-stroke/80 bg-white/70 px-4 py-2 font-semibold text-dark transition hover:border-primary/35 hover:text-primary disabled:cursor-not-allowed disabled:opacity-40 dark:border-dark-3 dark:bg-dark-2/70 dark:text-white"
>
Previous
</button>
<span
className="text-gray-600 dark:text-gray-300"
aria-current="page"
>
Page {pagination.currentPage + 1}
</span>
<button
type="button"
disabled={!hasNext}
onClick={() =>
handlePaginationChange({ selected: absoluteCurrentPage + 1 })
}
className="shadow-theme-xs rounded-xl border border-stroke/80 bg-white/70 px-4 py-2 font-semibold text-dark transition hover:border-primary/35 hover:text-primary disabled:cursor-not-allowed disabled:opacity-40 dark:border-dark-3 dark:bg-dark-2/70 dark:text-white"
>
Next
</button>
</nav>
</IsVisible> </IsVisible>
</ListWrapper> </ListWrapper>
); );
@@ -8,14 +8,20 @@ import { TCustomer } from "@/lib/api";
import { import {
buildQueryString, buildQueryString,
deleteEmptyKeys, deleteEmptyKeys,
getPaginatedRowNumber,
isDateRangeActive, isDateRangeActive,
unixToDate, unixToDate,
} from "@/utils/shared"; } from "@/utils/shared";
import gsap from "gsap"; import gsap from "gsap";
import { useIsMutating } from "@tanstack/react-query"; import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
const COMMA_SEPARATED_FILTER_KEYS = new Set([ const COMMA_SEPARATED_FILTER_KEYS = new Set([
@@ -173,6 +179,7 @@ const mergeTenderFilterFormState = (params: Record<string, any>) => {
return { return {
...TENDER_FILTER_FORM_DEFAULTS, ...TENDER_FILTER_FORM_DEFAULTS,
...built, ...built,
documents_scraped: built.documents_scraped === true,
created_at_range: built.created_at_range ?? [], created_at_range: built.created_at_range ?? [],
tender_deadline_range: built.tender_deadline_range ?? [], tender_deadline_range: built.tender_deadline_range ?? [],
publication_date_range: built.publication_date_range ?? [], publication_date_range: built.publication_date_range ?? [],
@@ -356,11 +363,10 @@ const useTenderListPresenter = () => {
useEffect(() => { useEffect(() => {
const newParams: Record<string, any> = { const newParams: Record<string, any> = {
...apiDefaultParams, ...apiDefaultParams,
offset: 0, limit: 20,
limit: 10,
}; };
for (const key of searchParams.keys()) { for (const key of searchParams.keys()) {
if (key === "cpv_codes") { if (key === "cpv_codes" || key === "include_total" || key === "cursor") {
continue; continue;
} }
newParams[key] = searchParams.get(key); newParams[key] = searchParams.get(key);
@@ -376,8 +382,13 @@ const useTenderListPresenter = () => {
const { data, error, isPending, isError } = useTendersQuery(params); const { data, error, isPending, isError } = useTendersQuery(params);
const { pagination, handlePaginationChange, buildFirstPageParams } = const {
useHybridPagination({ pagination,
handlePaginationChange: changePaginationPage,
buildFirstPageParams,
hasNext,
hasPrevious,
} = useHybridPagination({
meta: data?.meta, meta: data?.meta,
params, params,
setParams, setParams,
@@ -385,44 +396,23 @@ const useTenderListPresenter = () => {
error, error,
}); });
const MAX_PAGE_JUMP = 10; const handlePaginationChange = useCallback(
const paginationWindow = useMemo(() => { (event: { selected: number }) => {
const windowStart = Math.max(0, pagination.currentPage - MAX_PAGE_JUMP); if (typeof window !== "undefined") {
const windowEnd = Math.min( const reduceMotion = window.matchMedia(
Math.max(pagination.totalPages - 1, 0), "(prefers-reduced-motion: reduce)",
pagination.currentPage + MAX_PAGE_JUMP, ).matches;
); window.scrollTo({
return { top: 0,
windowStart, behavior: reduceMotion ? "auto" : "smooth",
virtualCurrent: pagination.currentPage - windowStart,
virtualTotal: Math.max(windowEnd - windowStart + 1, 1),
};
}, [pagination.currentPage, pagination.totalPages]);
const handleWindowedPaginationChange = useCallback(
(e: { selected: number }) => {
handlePaginationChange({
selected: e.selected + paginationWindow.windowStart,
}); });
}
changePaginationPage(event);
}, },
[handlePaginationChange, paginationWindow.windowStart], [changePaginationPage],
); );
const buildWindowedPageLabel = useCallback( const shouldShowPagination = !isPending && (hasPrevious || hasNext);
(virtualPage: number) => String(virtualPage + paginationWindow.windowStart),
[paginationWindow.windowStart],
);
const windowedPagination = useMemo(
() => ({
currentPage: paginationWindow.virtualCurrent,
totalPages: paginationWindow.virtualTotal,
}),
[paginationWindow.virtualCurrent, paginationWindow.virtualTotal],
);
const shouldShowPagination =
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const tendersList = data?.data?.tenders ?? []; const tendersList = data?.data?.tenders ?? [];
@@ -449,12 +439,11 @@ const useTenderListPresenter = () => {
const getRowNumber = useCallback( const getRowNumber = useCallback(
(index: number) => (index: number) =>
getPaginatedRowNumber({ pagination.currentPage *
index, (data?.meta?.limit ?? Number(params.limit) ?? 20) +
page: data?.meta?.page, index +
limit: data?.meta?.limit, 1,
}), [data?.meta?.limit, pagination.currentPage, params.limit],
[data?.meta?.limit, data?.meta?.page],
); );
const formatDateTimeCell = useCallback((unix: number) => { const formatDateTimeCell = useCallback((unix: number) => {
@@ -541,7 +530,7 @@ const useTenderListPresenter = () => {
const clearAllFilters = useCallback(() => { const clearAllFilters = useCallback(() => {
const cleared = buildFirstPageParams({ const cleared = buildFirstPageParams({
...apiDefaultParams, ...apiDefaultParams,
limit: 10, limit: 20,
}); });
filterFormReset(mergeTenderFilterFormState(cleared)); filterFormReset(mergeTenderFilterFormState(cleared));
router.push(pathName); router.push(pathName);
@@ -629,11 +618,12 @@ const useTenderListPresenter = () => {
tendersList, tendersList,
navigateToTenderDetails, navigateToTenderDetails,
navigateToTenderFeedback, navigateToTenderFeedback,
handlePaginationChange: handleWindowedPaginationChange, handlePaginationChange,
buildWindowedPageLabel, hasNext,
hasPrevious,
getRowNumber, getRowNumber,
formatDateTimeCell, formatDateTimeCell,
pagination: windowedPagination, pagination,
absoluteCurrentPage: pagination.currentPage, absoluteCurrentPage: pagination.currentPage,
}; };
}; };
+201
View File
@@ -0,0 +1,201 @@
"use client";
import InputGroup from "@/components/FormElements/InputGroup";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import { cn } from "@/lib/utils";
import type {
Control,
FieldErrors,
FieldValues,
Path,
RegisterOptions,
SubmitHandler,
UseFormHandleSubmit,
UseFormRegister,
} from "react-hook-form";
import { get } from "react-hook-form";
import type {
HTMLInputAutoCompleteAttribute,
HTMLInputTypeAttribute,
ReactNode,
} from "react";
import { memo } from "react";
export interface DynamicFieldContext<TValues extends FieldValues> {
control: Control<TValues>;
errors: FieldErrors<TValues>;
register: UseFormRegister<TValues>;
error?: string;
}
interface DynamicFieldBase<TValues extends FieldValues> {
name: Path<TValues>;
className?: string;
hidden?: boolean;
}
export interface DynamicInputField<
TValues extends FieldValues,
> extends DynamicFieldBase<TValues> {
kind: "input";
label: string;
type: HTMLInputTypeAttribute;
placeholder?: string;
required?: boolean;
disabled?: boolean;
autoComplete?: HTMLInputAutoCompleteAttribute;
dataCy?: string;
icon?: ReactNode;
iconPosition?: "left" | "right";
rules?: RegisterOptions<TValues, Path<TValues>>;
}
export interface DynamicCustomField<
TValues extends FieldValues,
> extends DynamicFieldBase<TValues> {
kind: "custom";
render: (context: DynamicFieldContext<TValues>) => ReactNode;
}
export type DynamicFormField<TValues extends FieldValues> =
| DynamicInputField<TValues>
| DynamicCustomField<TValues>;
export interface DynamicFormSection<TValues extends FieldValues> {
id: string;
title?: ReactNode;
className?: string;
rootClassName?: string;
fields: DynamicFormField<TValues>[];
}
interface DynamicFormBuilderProps<TValues extends FieldValues> {
sections: DynamicFormSection<TValues>[];
register: UseFormRegister<TValues>;
control: Control<TValues>;
errors: FieldErrors<TValues>;
handleSubmit: UseFormHandleSubmit<TValues>;
onSubmit: SubmitHandler<TValues>;
dataCy?: string;
className?: string;
header?: ReactNode;
sectionsClassName?: string;
footer?: ReactNode;
footerPlacement?: "last-section" | "form";
}
const getErrorMessage = <TValues extends FieldValues>(
errors: FieldErrors<TValues>,
name: Path<TValues>,
) => {
const message = get(errors, name)?.message;
return typeof message === "string" ? message : undefined;
};
interface DynamicSectionProps<TValues extends FieldValues> {
section: DynamicFormSection<TValues>;
register: UseFormRegister<TValues>;
control: Control<TValues>;
errors: FieldErrors<TValues>;
footer?: ReactNode;
}
const DynamicSectionComponent = <TValues extends FieldValues>({
section,
register,
control,
errors,
footer,
}: DynamicSectionProps<TValues>) => (
<ShowcaseSection
title={section.title}
rootClassName={section.rootClassName}
className={cn("grid grid-cols-1 gap-3 md:grid-cols-2", section.className)}
>
{section.fields.map((field) => {
if (field.hidden) return null;
if (field.kind === "custom") {
return (
<div key={field.name} className={field.className}>
{field.render({
control,
errors,
register,
error: getErrorMessage(errors, field.name),
})}
</div>
);
}
return (
<InputGroup<TValues>
key={field.name}
{...register(field.name, field.rules)}
name={field.name}
label={field.label}
type={field.type}
placeholder={field.placeholder}
required={field.required}
disabled={field.disabled}
autoComplete={field.autoComplete}
dataCy={field.dataCy}
icon={field.icon}
iconPosition={field.iconPosition}
className={field.className}
errors={errors}
/>
);
})}
{footer ? <div className="md:col-span-2">{footer}</div> : null}
</ShowcaseSection>
);
const DynamicSection = memo(
DynamicSectionComponent,
) as typeof DynamicSectionComponent;
const DynamicFormBuilder = <TValues extends FieldValues>({
sections,
register,
control,
errors,
handleSubmit,
onSubmit,
dataCy,
className,
header,
sectionsClassName,
footer,
footerPlacement = "last-section",
}: DynamicFormBuilderProps<TValues>) => (
<form
className={cn("grid grid-cols-1 gap-9", className)}
onSubmit={handleSubmit(onSubmit)}
data-cy={dataCy}
noValidate
>
{header}
<div className={sectionsClassName}>
{sections.map((section, sectionIndex) => (
<DynamicSection
key={section.id}
section={section}
register={register}
control={control}
errors={errors}
footer={
footerPlacement === "last-section" &&
sectionIndex === sections.length - 1
? footer
: undefined
}
/>
))}
</div>
{footerPlacement === "form" ? footer : null}
</form>
);
export default DynamicFormBuilder;
+19 -268
View File
@@ -1,284 +1,35 @@
"use client"; "use client";
import { PasswordIcon } from "@/assets/icons";
import InputGroup from "@/components/FormElements/InputGroup"; import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
import {
createPhoneFieldRules,
PhoneNumberInput,
} from "@/components/FormElements/PhoneNumberInput";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import FileImageUploadCard from "@/components/ui/FileImageUploadCard";
import FormFooter from "@/components/ui/FormFooter"; import FormFooter from "@/components/ui/FormFooter";
import { FormErrorMessages } from "@/constants/Texts";
import { ICreateAdminCredentials } from "@/lib/api"; import { ICreateAdminCredentials } from "@/lib/api";
import { FC } from "react"; import type { FC } from "react";
import { Controller } from "react-hook-form"; import useCreateAdminPresenter, {
import useCreateAdminPresenter from "./useCreateAdminPresenter"; type CreateAdminFormValues,
} from "./useCreateAdminPresenter";
interface IProps { interface IProps {
editMode?: boolean; editMode?: boolean;
defaultValues?: ICreateAdminCredentials; defaultValues?: ICreateAdminCredentials;
id?: string; id?: string;
} }
const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => { const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
const { const presenter = useCreateAdminPresenter({ editMode, defaultValues, id });
errors, const { errors, handleSubmit, register, control, onSubmit, sections } =
handleSubmit, presenter;
register,
control,
onSubmit,
isUploadingImage,
uploadProgress,
selectedFileName,
selectedFileSize,
previewUrl,
clearSelectedProfileImage,
profileImageRegister,
passwordFieldInputType,
setPasswordFieldInputType,
} = useCreateAdminPresenter({
editMode,
defaultValues,
id,
});
return ( return (
<form <DynamicFormBuilder<CreateAdminFormValues>
className="grid grid-cols-1 gap-9" sections={sections}
onSubmit={handleSubmit((data) => { register={register}
onSubmit(data);
})}
data-cy={editMode ? "admin-edit-form" : "admin-create-form"}
>
<div className="flex flex-col gap-9">
<ShowcaseSection
title="Input Fields"
className="grid grid-cols-1 gap-3 md:grid-cols-2"
>
<div>
<InputGroup
{...register("full_name", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 5,
message: FormErrorMessages.minLength(5),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="Full name"
placeholder="Enter full name (5-100 characters)"
required
type="text"
name="full_name"
dataCy="admin-form-full-name-input"
/>
{errors.full_name && (
<p className="mt-1 text-sm text-red-500">
{errors.full_name.message}
</p>
)}
</div>
<div>
<InputGroup
label="Username"
placeholder="Letters and numbers only (3-30)"
autoComplete="off"
required
type="text"
{...register("username", {
required: {
value: true,
message: FormErrorMessages.required,
},
pattern: {
value: /^[a-zA-Z0-9]+$/,
message: FormErrorMessages.alphaNumeric,
},
minLength: {
value: 3,
message: FormErrorMessages.minLength(3),
},
maxLength: {
value: 30,
message: FormErrorMessages.maxLength(30),
},
})}
name="username"
dataCy="admin-form-username-input"
/>
{errors.username && (
<p className="mt-1 text-sm text-red-500">
{errors.username.message}
</p>
)}
</div>
<div>
<InputGroup
label="Email"
autoComplete="off"
placeholder="you@example.com"
type="email"
required
{...register("email", {
required: {
value: true,
message: FormErrorMessages.required,
},
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: FormErrorMessages.invalidEmail,
},
})}
name="email"
dataCy="admin-form-email-input"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500">
{errors.email.message}
</p>
)}
</div>
{!editMode && (
<div>
<InputGroup
label="Password"
autoComplete="new-password"
placeholder="At least 8 characters"
required
icon={
<button
type="button"
onClick={() => {
setPasswordFieldInputType(
passwordFieldInputType === "password"
? "text"
: "password",
);
}}
className="absolute right-4.5 top-1/2 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-dark-5 transition hover:text-primary dark:text-white/75 [&>svg]:!static [&>svg]:!size-5 [&>svg]:!translate-y-0"
aria-label={
passwordFieldInputType === "password"
? "Show password"
: "Hide password"
}
>
<PasswordIcon className="pointer-events-none" />
</button>
}
type={passwordFieldInputType}
{...register("password", {
required: {
value: true,
message: FormErrorMessages.required,
},
minLength: {
value: 8,
message: FormErrorMessages.minLength(8),
},
maxLength: {
value: 128,
message: FormErrorMessages.maxLength(128),
},
})}
name="password"
dataCy="admin-form-password-input"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-500">
{errors.password.message}
</p>
)}
</div>
)}
<div>
<Controller
name="phone"
control={control} control={control}
rules={createPhoneFieldRules({ required: false })} errors={errors}
render={({ field }) => ( handleSubmit={handleSubmit}
<PhoneNumberInput onSubmit={onSubmit}
label="Phone" dataCy={editMode ? "admin-edit-form" : "admin-create-form"}
value={field.value ?? ""} footer={<FormFooter />}
onChange={field.onChange}
onBlur={field.onBlur}
error={errors.phone?.message as string | undefined}
dataCy="admin-form-phone-input"
/> />
)}
/>
</div>
<div>
<InputGroup
label="Department"
type="text"
{...register("department", {
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
name="department"
dataCy="admin-form-department-input"
/>
{errors.department && (
<p className="mt-1 text-sm text-red-500">
{errors.department.message}
</p>
)}
</div>
<div>
<InputGroup
label="Position"
type="text"
{...register("position", {
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
name="position"
dataCy="admin-form-position-input"
/>
{errors.position && (
<p className="mt-1 text-sm text-red-500">
{errors.position.message}
</p>
)}
</div>
<FileImageUploadCard
label="Profile image"
inputId="profile-image-upload"
fileRegister={profileImageRegister}
isLoading={isUploadingImage}
uploadProgress={uploadProgress}
previewUrl={previewUrl}
selectedFileName={selectedFileName}
selectedFileSize={selectedFileSize}
onClear={clearSelectedProfileImage}
errorMessage={errors.profile_image?.message as string | undefined}
/>
<div className="md:col-span-2">
<FormFooter />
</div>
</ShowcaseSection>
</div>
</form>
); );
}; };
@@ -0,0 +1,8 @@
import { ICreateAdminCredentials } from "@/lib/api";
export type CreateAdminFormValues = Omit<
ICreateAdminCredentials,
"profile_image"
> & {
profile_image?: FileList;
};
@@ -0,0 +1,192 @@
import { PasswordIcon } from "@/assets/icons";
import {
createPhoneFieldRules,
PhoneNumberInput,
} from "@/components/FormElements/PhoneNumberInput";
import type { DynamicFormSection } from "@/components/forms/DynamicFormBuilder";
import FileImageUploadCard from "@/components/ui/FileImageUploadCard";
import { FormErrorMessages } from "@/constants/Texts";
import type { Dispatch, SetStateAction } from "react";
import { Controller, type UseFormRegisterReturn } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
import type { CreateAdminFormValues } from "./createAdminForm.types";
const adminSectionId = uuidv4();
interface CreateAdminFormSectionsOptions {
editMode?: boolean;
passwordFieldInputType: "password" | "text";
setPasswordFieldInputType: Dispatch<SetStateAction<"password" | "text">>;
profileImageRegister: UseFormRegisterReturn;
isUploadingImage: boolean;
uploadProgress: number;
previewUrl: string | null;
selectedFileName: string;
selectedFileSize: number | null;
clearSelectedProfileImage: () => void;
}
export const createAdminFormSections = ({
editMode,
passwordFieldInputType,
setPasswordFieldInputType,
profileImageRegister,
isUploadingImage,
uploadProgress,
previewUrl,
selectedFileName,
selectedFileSize,
clearSelectedProfileImage,
}: CreateAdminFormSectionsOptions): DynamicFormSection<CreateAdminFormValues>[] => [
{
id: adminSectionId,
title: "Input Fields",
fields: [
{
kind: "input",
name: "full_name",
label: "Full name",
type: "text",
placeholder: "Enter full name (5-100 characters)",
required: true,
dataCy: "admin-form-full-name-input",
rules: {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
},
},
{
kind: "input",
name: "username",
label: "Username",
type: "text",
placeholder: "Letters and numbers only (3-30)",
autoComplete: "off",
required: true,
dataCy: "admin-form-username-input",
rules: {
required: { value: true, message: FormErrorMessages.required },
pattern: {
value: /^[a-zA-Z0-9]+$/,
message: FormErrorMessages.alphaNumeric,
},
minLength: { value: 3, message: FormErrorMessages.minLength(3) },
maxLength: { value: 30, message: FormErrorMessages.maxLength(30) },
},
},
{
kind: "input",
name: "email",
label: "Email",
type: "email",
placeholder: "you@example.com",
autoComplete: "off",
required: true,
dataCy: "admin-form-email-input",
rules: {
required: { value: true, message: FormErrorMessages.required },
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: FormErrorMessages.invalidEmail,
},
},
},
{
kind: "input",
name: "password",
label: "Password",
type: passwordFieldInputType,
placeholder: "At least 8 characters",
autoComplete: "new-password",
required: true,
hidden: editMode,
dataCy: "admin-form-password-input",
icon: (
<button
type="button"
onClick={() =>
setPasswordFieldInputType(
passwordFieldInputType === "password" ? "text" : "password",
)
}
className="absolute right-4.5 top-1/2 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-dark-5 transition hover:text-primary dark:text-white/75 [&>svg]:!static [&>svg]:!size-5 [&>svg]:!translate-y-0"
aria-label={
passwordFieldInputType === "password"
? "Show password"
: "Hide password"
}
>
<PasswordIcon className="pointer-events-none" />
</button>
),
rules: {
required: { value: true, message: FormErrorMessages.required },
minLength: { value: 8, message: FormErrorMessages.minLength(8) },
maxLength: { value: 128, message: FormErrorMessages.maxLength(128) },
},
},
{
kind: "custom",
name: "phone",
render: ({ control, errors }) => (
<Controller
name="phone"
control={control}
rules={createPhoneFieldRules({ required: false })}
render={({ field }) => (
<PhoneNumberInput
label="Phone"
value={field.value ?? ""}
onChange={field.onChange}
onBlur={field.onBlur}
error={errors.phone?.message as string | undefined}
dataCy="admin-form-phone-input"
/>
)}
/>
),
},
{
kind: "input",
name: "department",
label: "Department",
type: "text",
dataCy: "admin-form-department-input",
rules: {
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
},
},
{
kind: "input",
name: "position",
label: "Position",
type: "text",
dataCy: "admin-form-position-input",
rules: {
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
},
},
{
kind: "custom",
name: "profile_image",
render: ({ error }) => (
<FileImageUploadCard
label="Profile image"
inputId="profile-image-upload"
fileRegister={profileImageRegister}
isLoading={isUploadingImage}
uploadProgress={uploadProgress}
previewUrl={previewUrl}
selectedFileName={selectedFileName}
selectedFileSize={selectedFileSize}
onClear={clearSelectedProfileImage}
errorMessage={error}
/>
),
},
],
},
];
@@ -5,9 +5,13 @@ import { normalizePhoneFieldValue } from "@/components/FormElements/PhoneNumberI
import { validateImageFile } from "@/utils/shared"; import { validateImageFile } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query"; import { useIsMutating } from "@tanstack/react-query";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ChangeEvent, useEffect, useState } from "react"; import { ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form"; import { SubmitHandler, useForm } from "react-hook-form";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { createAdminFormSections } from "./createAdminFormSections";
import type { CreateAdminFormValues } from "./createAdminForm.types";
export type { CreateAdminFormValues } from "./createAdminForm.types";
interface IProps { interface IProps {
editMode?: boolean; editMode?: boolean;
@@ -15,10 +19,6 @@ interface IProps {
id?: string; id?: string;
} }
type CreateAdminFormValues = Omit<ICreateAdminCredentials, "profile_image"> & {
profile_image?: FileList;
};
const useCreateAdminPresenter = ({ const useCreateAdminPresenter = ({
editMode = false, editMode = false,
defaultValues, defaultValues,
@@ -33,8 +33,9 @@ const useCreateAdminPresenter = ({
const [isUploadingImage, setIsUploadingImage] = useState(false); const [isUploadingImage, setIsUploadingImage] = useState(false);
const [uploadProgress, setUploadProgress] = useState<number>(0); const [uploadProgress, setUploadProgress] = useState<number>(0);
const [uploadedProfileImageUrl, setUploadedProfileImageUrl] = useState< const [uploadedProfileImageUrl, setUploadedProfileImageUrl] = useState<
string | undefined string | null | undefined
>(defaultValues?.profile_image); >(defaultValues?.profile_image);
const profileImageInputRef = useRef<HTMLInputElement | null>(null);
// The existing image rendered as a local (authenticated) object URL — used as // The existing image rendered as a local (authenticated) object URL — used as
// the preview baseline so "remove/replace" can fall back to it. // the preview baseline so "remove/replace" can fall back to it.
const [defaultPreviewUrl, setDefaultPreviewUrl] = useState<string | null>( const [defaultPreviewUrl, setDefaultPreviewUrl] = useState<string | null>(
@@ -144,8 +145,15 @@ const useCreateAdminPresenter = ({
}; };
const clearSelectedProfileImage = () => { const clearSelectedProfileImage = () => {
if (profileImageInputRef.current) {
profileImageInputRef.current.value = "";
}
setValue("profile_image", undefined); setValue("profile_image", undefined);
resetToDefaultProfileImage(); setSelectedFileName("");
setSelectedFileSize(null);
setUploadProgress(0);
setUploadedProfileImageUrl(null);
setPreviewUrl(null);
clearErrors("profile_image"); clearErrors("profile_image");
}; };
@@ -204,10 +212,17 @@ const useCreateAdminPresenter = ({
const profileImageRegister = register("profile_image", { const profileImageRegister = register("profile_image", {
onChange: onProfileImageChange, onChange: onProfileImageChange,
}); });
const profileImageFileRegister = {
...profileImageRegister,
ref: (element: HTMLInputElement | null) => {
profileImageInputRef.current = element;
profileImageRegister.ref(element);
},
};
const onSubmit: SubmitHandler<CreateAdminFormValues> = async (data) => { const onSubmit: SubmitHandler<CreateAdminFormValues> = async (data) => {
const file = data.profile_image?.[0]; const file = data.profile_image?.[0];
const profileImageUrl = uploadedProfileImageUrl ?? defaultValues?.profile_image; const profileImageUrl = uploadedProfileImageUrl;
if (file && !profileImageUrl) { if (file && !profileImageUrl) {
toast.error("Please wait for profile image upload to finish"); toast.error("Please wait for profile image upload to finish");
@@ -229,6 +244,34 @@ const useCreateAdminPresenter = ({
} }
}; };
const router = useRouter(); const router = useRouter();
const sections = useMemo(
() =>
createAdminFormSections({
editMode,
passwordFieldInputType,
setPasswordFieldInputType,
profileImageRegister: profileImageFileRegister,
isUploadingImage,
uploadProgress,
previewUrl,
selectedFileName,
selectedFileSize,
clearSelectedProfileImage,
}),
[
editMode,
passwordFieldInputType,
profileImageFileRegister,
isUploadingImage,
uploadProgress,
previewUrl,
selectedFileName,
selectedFileSize,
clearSelectedProfileImage,
],
);
return { return {
errors, errors,
register, register,
@@ -244,9 +287,10 @@ const useCreateAdminPresenter = ({
selectedFileSize, selectedFileSize,
previewUrl, previewUrl,
clearSelectedProfileImage, clearSelectedProfileImage,
profileImageRegister, profileImageRegister: profileImageFileRegister,
passwordFieldInputType, passwordFieldInputType,
setPasswordFieldInputType, setPasswordFieldInputType,
sections,
}; };
}; };
+33 -467
View File
@@ -1,16 +1,9 @@
"use client"; "use client";
import { GlobeIcon, MoneyIcon } from "@/assets/icons";
import InputGroup from "@/components/FormElements/InputGroup"; import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
import TagInput from "@/components/FormElements/InputGroup/tag-input";
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
import MultiSelect from "@/components/FormElements/MultiSelect";
import { Select } from "@/components/FormElements/select";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import FormFooter from "@/components/ui/FormFooter"; import FormFooter from "@/components/ui/FormFooter";
import { REGEX } from "@/constants/regex"; import type { ICreateCompanyCredentials } from "@/lib/api";
import { FormErrorMessages } from "@/constants/Texts"; import { createCompanyFormSections } from "./companyFormSections";
import { ICreateCompanyCredentials } from "@/lib/api/types/Company";
import CompanyDocuments from "./documents/CompanyDocuments";
import useCreateCompanyPresenter from "./useCreateCompanyPresenter"; import useCreateCompanyPresenter from "./useCreateCompanyPresenter";
interface IProps { interface IProps {
@@ -19,482 +12,55 @@ interface IProps {
id?: string; id?: string;
} }
/** Inline error for nested fields whose dotted name can't use the inputs' built-in lookup. */
const FieldError = ({ message }: { message?: string }) =>
message ? (
<p className="mt-1.5 text-sm font-medium text-error">{message}</p>
) : null;
const CreateCompany = ({ defaultValues, editMode, id }: IProps) => { const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
const { const {
errors, errors,
handleSubmit, handleSubmit,
onSubmit, onSubmit,
register, register,
control,
watch, watch,
setValue, setValue,
persistDocumentFileIds,
companyCategories, companyCategories,
} = useCreateCompanyPresenter({ } = useCreateCompanyPresenter({ editMode, defaultValues, id });
editMode,
defaultValues, const categoryItems =
id, companyCategories?.data?.categories?.map((category) => ({
}); label: category.name,
value: category.id,
})) ?? [];
return ( return (
<form className="flex flex-col gap-6" onSubmit={handleSubmit(onSubmit)}> <DynamicFormBuilder<ICreateCompanyCredentials>
sections={createCompanyFormSections({
editMode,
id,
categoryItems,
watch,
setValue,
persistDocumentFileIds,
})}
register={register}
control={control}
errors={errors}
handleSubmit={handleSubmit}
onSubmit={onSubmit}
className="flex flex-col gap-6"
sectionsClassName="grid grid-cols-1 items-start gap-6 lg:grid-cols-2"
header={
<header className="flex flex-col gap-1"> <header className="flex flex-col gap-1">
<h1 className="text-xl font-bold text-dark dark:text-white"> <h1 className="text-xl font-bold text-dark dark:text-white">
{editMode ? "Edit company" : "New company"} {editMode ? "Edit company" : "New company"}
</h1> </h1>
<p className="text-sm text-dark-5 dark:text-dark-6"> <p className="text-sm text-dark-5 dark:text-dark-6">
Profile, business details, address, tags, and supporting documents. Profile, business details, address, tags, documents, and links.
</p> </p>
</header> </header>
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-2">
<ShowcaseSection
title="Company information"
rootClassName="lg:col-span-2"
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
>
<InputGroup
{...register("name", {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: {
value: 200,
message: FormErrorMessages.maxLength(200),
},
})}
errors={errors}
label="Name"
name="name"
required
type="text"
placeholder="Legal name as registered"
className="sm:col-span-2"
/>
<InputGroup
{...register("email", {
required: { message: FormErrorMessages.required, value: true },
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: FormErrorMessages.invalidEmail,
},
})}
errors={errors}
label="Email"
name="email"
required
type="email"
placeholder="name@company.com"
/>
<InputGroup
{...register("phone", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 10,
message: FormErrorMessages.minLength(10),
},
maxLength: {
value: 20,
message: FormErrorMessages.maxLength(20),
},
pattern: {
value: REGEX.PHONE,
message: FormErrorMessages.invalidPattern,
},
})}
errors={errors}
label="Phone"
name="phone"
required
type="tel"
placeholder="+971 50 123 4567"
/>
<Select
{...register("type", {
required: { message: FormErrorMessages.required, value: true },
})}
errors={errors}
name="type"
label="Company type"
required
items={[
{ value: "private", label: "Private" },
{ value: "public", label: "Public" },
{ value: "government", label: "Government" },
{ value: "ngo", label: "NGO" },
{ value: "startup", label: "Startup" },
]}
defaultValue="Private"
prefixIcon={<GlobeIcon />}
/>
<InputGroup
{...register("industry", {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
errors={errors}
label="Industry"
name="industry"
type="text"
required
placeholder="e.g. construction, software, logistics"
/>
<InputGroup
{...register("registration_number", {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
maxLength: {
value: 50,
message: FormErrorMessages.maxLength(50),
},
})}
errors={errors}
label="Registration number"
name="registration_number"
type="text"
required
placeholder="Registry or license number"
/>
<InputGroup
{...register("tax_id", {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
maxLength: {
value: 50,
message: FormErrorMessages.maxLength(50),
},
})}
errors={errors}
label="Tax id"
name="tax_id"
type="text"
required
placeholder="National tax identification number"
/>
<InputGroup
{...register("website", {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
pattern: {
value: REGEX.URL,
message: FormErrorMessages.invalidPattern,
},
})}
errors={errors}
label="Website"
required
name="website"
type="text"
placeholder="https://www.example.com"
className="sm:col-span-2"
/>
<TextAreaGroup
label="Description"
{...register("description", {
minLength: {
value: 10,
message: FormErrorMessages.minLength(10),
},
maxLength: {
value: 1000,
message: FormErrorMessages.maxLength(1000),
},
})}
errors={errors}
name="description"
className="sm:col-span-2"
/>
</ShowcaseSection>
<ShowcaseSection
title="Business information"
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
>
<InputGroup
{...register("employee_count", {
min: { value: 1, message: FormErrorMessages.min(1) },
max: { value: 100000, message: FormErrorMessages.max(100000) },
})}
errors={errors}
label="Employee count"
name="employee_count"
type="number"
/>
<InputGroup
{...register("annual_revenue", {
min: { value: 0, message: FormErrorMessages.min(0) },
max: {
value: 999999999999,
message: FormErrorMessages.max(999999999999),
},
})}
errors={errors}
label="Annual revenue"
name="annual_revenue"
type="number"
/>
<InputGroup
{...register("founded_year", {
min: { value: 1800, message: FormErrorMessages.min(1800) },
max: { value: 2100, message: FormErrorMessages.max(2100) },
})}
errors={errors}
label="Founded year"
name="founded_year"
type="number"
className="sm:col-span-2"
/>
</ShowcaseSection>
<ShowcaseSection
title="Settings"
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
>
<Select
{...register("language")}
errors={errors}
name="language"
label="Language"
items={[
{ value: "en", label: "EN" },
{ value: "ar", label: "AR" },
{ value: "fr", label: "FR" },
{ value: "es", label: "ES" },
{ value: "de", label: "DE" },
{ value: "zh", label: "ZH" },
{ value: "jEna", label: "jEna" },
{ value: "ko", label: "KO" },
]}
defaultValue="en"
prefixIcon={<GlobeIcon />}
/>
<Select
{...register("currency")}
errors={errors}
name="currency"
label="Currency"
items={[
{ label: "USD", value: "USD" },
{ label: "EUR", value: "EUR" },
{ label: "GBP", value: "GBP" },
{ label: "JPY", value: "JPY" },
{ label: "CAD", value: "CAD" },
{ label: "AUD", value: "AUD" },
{ label: "CHF", value: "CHF" },
{ label: "CNY", value: "CNY" },
{ label: "INR", value: "INR" },
{ label: "BRL", value: "BRL" },
]}
defaultValue="en"
prefixIcon={<MoneyIcon />}
/>
<InputGroup
{...register("timezone", {
minLength: { value: 3, message: FormErrorMessages.minLength(3) },
maxLength: {
value: 50,
message: FormErrorMessages.maxLength(50),
},
})}
errors={errors}
label="Timezone"
name="timezone"
type="text"
className="sm:col-span-2"
/>
</ShowcaseSection>
<ShowcaseSection
title="Address"
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
>
<div className="sm:col-span-2">
<InputGroup
{...register("address.street", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 5,
message: FormErrorMessages.minLength(5),
},
maxLength: {
value: 200,
message: FormErrorMessages.maxLength(200),
},
})}
label="Street"
name="address.street"
required
type="text"
placeholder="Building, street, suite or unit"
/>
<FieldError message={errors.address?.street?.message} />
</div>
<div>
<InputGroup
{...register("address.city", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="City"
required
name="address.city"
type="text"
placeholder="City or town"
/>
<FieldError message={errors.address?.city?.message} />
</div>
<div>
<InputGroup
{...register("address.state", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="State"
name="address.state"
type="text"
required
placeholder="State, province, or region"
/>
<FieldError message={errors.address?.state?.message} />
</div>
<div>
<InputGroup
{...register("address.postal_code", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 3,
message: FormErrorMessages.minLength(3),
},
maxLength: {
value: 20,
message: FormErrorMessages.maxLength(20),
},
})}
label="Postal code"
name="address.postal_code"
type="text"
required
placeholder="Postal or ZIP code"
/>
<FieldError message={errors.address?.postal_code?.message} />
</div>
<div>
<InputGroup
{...register("address.country", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="Country"
name="address.country"
type="text"
required
placeholder="Full country name"
/>
<FieldError message={errors.address?.country?.message} />
</div>
</ShowcaseSection>
<ShowcaseSection
title="Tags"
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
>
<TagInput
{...register("tags.keywords")}
name="tags.keywords"
label="Keywords"
placeholder=""
register={register}
setValue={setValue}
watch={watch}
/>
<MultiSelect
{...register("tags.categories")}
value={watch("tags.categories")}
name="tags.categories"
label="Categories"
items={
companyCategories
? companyCategories?.data?.categories?.map((c) => ({
label: c.name,
value: c.id,
}))
: []
} }
placeholder="" footer={<FormFooter />}
footerPlacement="form"
/> />
<TagInput
{...register("tags.cpv_codes")}
label="CPV codes"
name="tags.cpv_codes"
shouldAddWithSpace
watch={watch}
setValue={setValue}
placeholder=""
/>
<TagInput
{...register("tags.certifications")}
label="Certifications"
name="tags.certifications"
watch={watch}
setValue={setValue}
placeholder=""
/>
<div className="sm:col-span-2">
<TagInput
{...register("tags.specializations")}
label="Specializations"
name="tags.specializations"
watch={watch}
setValue={setValue}
placeholder=""
/>
<FieldError message={errors.tags?.specializations?.message} />
</div>
</ShowcaseSection>
<ShowcaseSection title="Documents" rootClassName="lg:col-span-2">
<CompanyDocuments
companyId={editMode ? id : undefined}
value={watch("document_file_ids") ?? []}
onChange={(ids) => setValue("document_file_ids", ids)}
/>
</ShowcaseSection>
</div>
<FormFooter />
</form>
); );
}; };
@@ -1,10 +1,9 @@
"use client"; "use client";
import InputGroup from "@/components/FormElements/InputGroup";
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area"; import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import FormFooter from "@/components/ui/FormFooter"; import FormFooter from "@/components/ui/FormFooter";
import { FormErrorMessages } from "@/constants/Texts"; import type { TCreateCompanyCategoryCredentials } from "@/lib/api";
import { TCreateCompanyCategoryCredentials } from "@/lib/api"; import { createCompanyCategoryFormSections } from "./companyCategoryFormSections";
import useCreateCompanyCategoryPresenter from "./useCreateCompanyCategoryPresenter"; import useCreateCompanyCategoryPresenter from "./useCreateCompanyCategoryPresenter";
interface IProps { interface IProps {
@@ -14,72 +13,20 @@ interface IProps {
} }
const CreateCompanyCategoryForm = ({ defaultValues, editMode, id }: IProps) => { const CreateCompanyCategoryForm = ({ defaultValues, editMode, id }: IProps) => {
const { errors, handleSubmit, onSubmit, register } = const { errors, handleSubmit, onSubmit, register, control } =
useCreateCompanyCategoryPresenter({ defaultValues, editMode, id }); useCreateCompanyCategoryPresenter({ defaultValues, editMode, id });
return ( return (
<form <DynamicFormBuilder<TCreateCompanyCategoryCredentials>
className="grid grid-cols-1 gap-9" sections={createCompanyCategoryFormSections(editMode)}
data-cy="company-category-form" register={register}
onSubmit={handleSubmit(onSubmit)} control={control}
> errors={errors}
<ShowcaseSection handleSubmit={handleSubmit}
title={editMode ? "Edit Company Category" : "Create Company Category"} onSubmit={onSubmit}
className="grid grid-cols-1 gap-5 md:grid-cols-2" dataCy="company-category-form"
> footer={<FormFooter />}
<div className="flex flex-col gap-2 md:col-span-1">
<InputGroup
label="Name"
required
dataCy="company-category-form-name-input"
{...register("name", {
required: {
value: true,
message: FormErrorMessages.required,
},
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
type="text"
name="name"
placeholder="e.g. Technology"
/> />
{errors.name && (
<p className="mt-1 text-sm text-red-500">{errors.name.message}</p>
)}
</div>
<div className="flex flex-col gap-2 md:col-span-1">
<TextAreaGroup
label="Description"
dataCy="company-category-form-description-input"
{...register("description", {
minLength: {
value: 10,
message: FormErrorMessages.minLength(10),
},
maxLength: {
value: 1000,
message: FormErrorMessages.maxLength(1000),
},
})}
name="description"
/>
{errors.description && (
<p className="mt-1 text-sm text-red-500">
{errors.description.message}
</p>
)}
</div>
<div className="md:col-span-2">
<FormFooter />
</div>
</ShowcaseSection>
</form>
); );
}; };
@@ -0,0 +1,66 @@
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
import type { DynamicFormSection } from "@/components/forms/DynamicFormBuilder";
import { FormErrorMessages } from "@/constants/Texts";
import type { TCreateCompanyCategoryCredentials } from "@/lib/api";
import { v4 as uuidv4 } from "uuid";
const companyCategorySectionId = uuidv4();
export const createCompanyCategoryFormSections = (
editMode?: boolean,
): DynamicFormSection<TCreateCompanyCategoryCredentials>[] => [
{
id: companyCategorySectionId,
title: editMode ? "Edit Company Category" : "Create Company Category",
className: "gap-5",
fields: [
{
kind: "input",
name: "name",
label: "Name",
type: "text",
placeholder: "e.g. Technology",
required: true,
dataCy: "company-category-form-name-input",
className: "md:col-span-1",
rules: {
required: {
value: true,
message: FormErrorMessages.required,
},
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
},
},
{
kind: "custom",
name: "description",
className: "md:col-span-1",
render: ({ register, errors }) => (
<TextAreaGroup<TCreateCompanyCategoryCredentials>
label="Description"
dataCy="company-category-form-description-input"
{...register("description", {
minLength: {
value: 10,
message: FormErrorMessages.minLength(10),
},
maxLength: {
value: 1000,
message: FormErrorMessages.maxLength(1000),
},
})}
name="description"
errors={errors}
/>
),
},
],
},
];
@@ -20,6 +20,7 @@ const useCreateCompanyCategoryPresenter = ({
}: IProps) => { }: IProps) => {
const { const {
register, register,
control,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
} = useForm<TCreateCompanyCategoryCredentials>({ } = useForm<TCreateCompanyCategoryCredentials>({
@@ -38,6 +39,7 @@ const useCreateCompanyCategoryPresenter = ({
}; };
return { return {
register, register,
control,
onSubmit, onSubmit,
handleSubmit, handleSubmit,
errors, errors,
@@ -0,0 +1,686 @@
import { GlobeIcon, MoneyIcon } from "@/assets/icons";
import TagInput from "@/components/FormElements/InputGroup/tag-input";
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
import MultiSelect from "@/components/FormElements/MultiSelect";
import { Select } from "@/components/FormElements/select";
import type { DynamicFormSection } from "@/components/forms/DynamicFormBuilder";
import { REGEX } from "@/constants/regex";
import { FormErrorMessages } from "@/constants/Texts";
import type { ICreateCompanyCredentials } from "@/lib/api";
import type { ILabelValue } from "@/types/shared";
import type { UseFormSetValue, UseFormWatch } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
import CompanyDocuments from "./documents/CompanyDocuments";
import CompanyLinks from "./links/CompanyLinks";
const companyInformationSectionId = uuidv4();
const businessInformationSectionId = uuidv4();
const settingsSectionId = uuidv4();
const addressSectionId = uuidv4();
const tagsSectionId = uuidv4();
const documentsSectionId = uuidv4();
const linksSectionId = uuidv4();
interface CompanyFormSectionsOptions {
editMode?: boolean;
id?: string;
categoryItems: ILabelValue[];
watch: UseFormWatch<ICreateCompanyCredentials>;
setValue: UseFormSetValue<ICreateCompanyCredentials>;
persistDocumentFileIds?: (ids: string[]) => Promise<void>;
}
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 languageItems: ILabelValue[] = [
{ value: "en", label: "EN" },
{ value: "ar", label: "AR" },
{ value: "fr", label: "FR" },
{ value: "es", label: "ES" },
{ value: "de", label: "DE" },
{ value: "zh", label: "ZH" },
{ value: "jEna", label: "jEna" },
{ value: "ko", label: "KO" },
];
const currencyItems: ILabelValue[] = [
{ label: "USD", value: "USD" },
{ label: "EUR", value: "EUR" },
{ label: "GBP", value: "GBP" },
{ label: "JPY", value: "JPY" },
{ label: "CAD", value: "CAD" },
{ label: "AUD", value: "AUD" },
{ label: "CHF", value: "CHF" },
{ label: "CNY", value: "CNY" },
{ label: "INR", value: "INR" },
{ label: "BRL", value: "BRL" },
];
const allTimezones: Record<string, string> = {
"Pacific/Midway": "Midway Island, Samoa",
"Pacific/Honolulu": "Hawaii",
"America/Juneau": "Alaska",
"America/Boise": "Mountain Time",
"America/Dawson": "Dawson, Yukon",
"America/Chihuahua": "Chihuahua, La Paz, Mazatlan",
"America/Phoenix": "Arizona",
"America/Los_Angeles": "Pacific Time",
"America/Chicago": "Central Time",
"America/Regina": "Saskatchewan",
"America/Mexico_City": "Guadalajara, Mexico City, Monterrey",
"America/Belize": "Central America",
"America/Detroit": "Eastern Time",
"America/Bogota": "Bogota, Lima, Quito",
"America/Caracas": "Caracas, La Paz",
"America/Santiago": "Santiago",
"America/St_Johns": "Newfoundland and Labrador",
"America/Sao_Paulo": "Brasilia",
"America/Tijuana": "Tijuana",
"America/Montevideo": "Montevideo",
"America/Argentina/Buenos_Aires": "Buenos Aires, Georgetown",
"America/Godthab": "Greenland",
"Atlantic/Azores": "Azores",
"Atlantic/Cape_Verde": "Cape Verde Islands",
GMT: "UTC",
"Europe/London": "Edinburgh, London",
"Europe/Dublin": "Dublin",
"Europe/Lisbon": "Lisbon",
"Africa/Casablanca": "Casablanca, Monrovia",
"Atlantic/Canary": "Canary Islands",
"Europe/Belgrade": "Belgrade, Bratislava, Budapest, Ljubljana, Prague",
"Europe/Sarajevo": "Sarajevo, Skopje, Warsaw, Zagreb",
"Europe/Brussels": "Brussels, Copenhagen, Madrid, Paris",
"Europe/Amsterdam": "Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
"Africa/Algiers": "West Central Africa",
"Europe/Bucharest": "Bucharest",
"Africa/Cairo": "Cairo",
"Europe/Helsinki": "Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius",
"Europe/Athens": "Athens",
"Asia/Jerusalem": "Jerusalem",
"Africa/Harare": "Harare, Pretoria",
"Europe/Moscow": "Istanbul, Minsk, Moscow, St. Petersburg, Volgograd",
"Asia/Kuwait": "Kuwait, Riyadh",
"Africa/Nairobi": "Nairobi",
"Asia/Baghdad": "Baghdad",
"Asia/Tehran": "Tehran",
"Asia/Dubai": "Abu Dhabi, Muscat",
"Asia/Baku": "Baku, Tbilisi, Yerevan",
"Asia/Kabul": "Kabul",
"Asia/Yekaterinburg": "Ekaterinburg",
"Asia/Karachi": "Islamabad, Karachi, Tashkent",
"Asia/Kolkata": "Chennai, Kolkata, Mumbai, New Delhi",
"Asia/Kathmandu": "Kathmandu",
"Asia/Dhaka": "Astana, Dhaka",
"Asia/Colombo": "Sri Jayawardenepura",
"Asia/Almaty": "Almaty, Novosibirsk",
"Asia/Rangoon": "Yangon Rangoon",
"Asia/Bangkok": "Bangkok, Hanoi, Jakarta",
"Asia/Krasnoyarsk": "Krasnoyarsk",
"Asia/Shanghai": "Beijing, Chongqing, Hong Kong SAR, Urumqi",
"Asia/Kuala_Lumpur": "Kuala Lumpur, Singapore",
"Asia/Taipei": "Taipei",
"Australia/Perth": "Perth",
"Asia/Irkutsk": "Irkutsk, Ulaanbaatar",
"Asia/Seoul": "Seoul",
"Asia/Tokyo": "Osaka, Sapporo, Tokyo",
"Asia/Yakutsk": "Yakutsk",
"Australia/Darwin": "Darwin",
"Australia/Adelaide": "Adelaide",
"Australia/Sydney": "Canberra, Melbourne, Sydney",
"Australia/Brisbane": "Brisbane",
"Australia/Hobart": "Hobart",
"Asia/Vladivostok": "Vladivostok",
"Pacific/Guam": "Guam, Port Moresby",
"Asia/Magadan": "Magadan, Solomon Islands, New Caledonia",
"Asia/Kamchatka": "Kamchatka, Marshall Islands",
"Pacific/Fiji": "Fiji Islands",
"Pacific/Auckland": "Auckland, Wellington",
"Pacific/Tongatapu": "Nuku'alofa",
};
const getTimezoneOffsetMinutes = (timezone: string, date = new Date()) => {
try {
const formatter = new Intl.DateTimeFormat("en", {
timeZone: timezone,
timeZoneName: "shortOffset",
});
const offset =
formatter.formatToParts(date).find((part) => part.type === "timeZoneName")
?.value ?? "";
const label = offset.replace(/^GMT/, "") || "+0";
const match = label.match(/^([+-])(\d{1,2})(?::(\d{2}))?$/);
if (!match) return 0;
const sign = match[1] === "-" ? -1 : 1;
const hours = Number(match[2]);
const minutes = Number(match[3] ?? 0);
return sign * (hours * 60 + minutes);
} catch {
return 0;
}
};
const formatTimezoneOffset = (offsetMinutes: number) => {
const sign = offsetMinutes < 0 ? "-" : "+";
const absoluteMinutes = Math.abs(offsetMinutes);
const hours = Math.trunc(absoluteMinutes / 60);
const minutes = absoluteMinutes % 60;
return `${sign}${hours}:${minutes.toString().padStart(2, "0")}`;
};
const hasTimezoneDst = (timezone: string) => {
const januaryOffset = getTimezoneOffsetMinutes(
timezone,
new Date(new Date().getFullYear(), 0, 1),
);
const julyOffset = getTimezoneOffsetMinutes(
timezone,
new Date(new Date().getFullYear(), 6, 1),
);
return januaryOffset !== julyOffset;
};
const timezoneItems: ILabelValue[] = Object.entries(allTimezones)
.map(([timezone, name]) => {
const offsetMinutes = getTimezoneOffsetMinutes(timezone);
return {
timezone,
name,
hasDst: hasTimezoneDst(timezone),
offsetMinutes,
};
})
.sort((a, b) => a.offsetMinutes - b.offsetMinutes)
.filter((item, index, items) => {
return (
items.findIndex(
(timezone) =>
timezone.offsetMinutes === item.offsetMinutes &&
timezone.hasDst === item.hasDst,
) === index
);
})
.map(({ timezone, name, offsetMinutes }) => ({
label: `(GMT${formatTimezoneOffset(offsetMinutes)}) ${name}`,
value: timezone,
}));
export const createCompanyFormSections = ({
editMode,
id,
categoryItems,
watch,
setValue,
persistDocumentFileIds,
}: CompanyFormSectionsOptions): DynamicFormSection<ICreateCompanyCredentials>[] => [
{
id: companyInformationSectionId,
title: "Company information",
rootClassName: "lg:col-span-2",
className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2",
fields: [
{
kind: "input",
name: "name",
label: "Name",
type: "text",
placeholder: "Legal name as registered",
required: true,
className: "sm:col-span-2",
rules: {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: { value: 200, message: FormErrorMessages.maxLength(200) },
},
},
{
kind: "input",
name: "email",
label: "Email",
type: "email",
placeholder: "name@company.com",
required: true,
rules: {
required: { message: FormErrorMessages.required, value: true },
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: FormErrorMessages.invalidEmail,
},
},
},
{
kind: "input",
name: "phone",
label: "Phone",
type: "tel",
placeholder: "+971 50 123 4567",
required: true,
rules: {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 10, message: FormErrorMessages.minLength(10) },
maxLength: { value: 20, message: FormErrorMessages.maxLength(20) },
pattern: {
value: REGEX.PHONE,
message: FormErrorMessages.invalidPattern,
},
},
},
{
kind: "custom",
name: "type",
render: ({ register, errors }) => (
<Select<ICreateCompanyCredentials>
{...register("type", {
required: { message: FormErrorMessages.required, value: true },
})}
errors={errors}
name="type"
label="Company type"
required
items={[
{ value: "private", label: "Private" },
{ value: "public", label: "Public" },
{ value: "government", label: "Government" },
{ value: "ngo", label: "NGO" },
{ value: "startup", label: "Startup" },
]}
defaultValue="Private"
prefixIcon={<GlobeIcon />}
/>
),
},
{
kind: "input",
name: "industry",
label: "Industry",
type: "text",
placeholder: "e.g. construction, software, logistics",
rules: {
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
},
},
{
kind: "input",
name: "registration_number",
label: "Registration number",
type: "text",
placeholder: "Registry or license number",
rules: {
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
maxLength: { value: 50, message: FormErrorMessages.maxLength(50) },
},
},
{
kind: "input",
name: "tax_id",
label: "Tax id",
type: "text",
placeholder: "National tax identification number",
rules: {
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
maxLength: { value: 50, message: FormErrorMessages.maxLength(50) },
},
},
{
kind: "input",
name: "website",
label: "Website",
type: "text",
placeholder: "https://www.example.com",
required: true,
className: "sm:col-span-2",
rules: {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
pattern: {
value: REGEX.URL,
message: FormErrorMessages.invalidPattern,
},
},
},
{
kind: "custom",
name: "description",
className: "sm:col-span-2",
render: ({ register, errors }) => (
<TextAreaGroup<ICreateCompanyCredentials>
label="Description"
{...register("description", {
minLength: {
value: 10,
message: FormErrorMessages.minLength(10),
},
maxLength: {
value: 1000,
message: FormErrorMessages.maxLength(1000),
},
})}
errors={errors}
name="description"
/>
),
},
],
},
{
id: businessInformationSectionId,
title: "Business information",
className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2",
fields: [
{
kind: "input",
name: "employee_count",
label: "Employee count",
type: "number",
rules: {
setValueAs: optionalNumber,
validate: optionalNumberRange({ min: 1, max: 100000, integer: true }),
},
},
{
kind: "input",
name: "annual_revenue",
label: "Annual revenue",
type: "number",
rules: {
setValueAs: optionalNumber,
validate: optionalNumberRange({ min: 0, max: 999999999999 }),
},
},
{
kind: "input",
name: "founded_year",
label: "Founded year",
type: "number",
className: "sm:col-span-2",
rules: {
setValueAs: optionalNumber,
validate: optionalNumberRange({
min: 1800,
max: 2100,
integer: true,
}),
},
},
],
},
{
id: settingsSectionId,
title: "Settings",
className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2",
fields: [
{
kind: "custom",
name: "language",
render: ({ register, errors }) => (
<Select<ICreateCompanyCredentials>
{...register("language")}
errors={errors}
name="language"
label="Language"
items={languageItems}
defaultValue="en"
prefixIcon={<GlobeIcon />}
/>
),
},
{
kind: "custom",
name: "currency",
render: ({ register, errors }) => (
<Select<ICreateCompanyCredentials>
{...register("currency")}
errors={errors}
name="currency"
label="Currency"
items={currencyItems}
defaultValue="USD"
prefixIcon={<MoneyIcon />}
/>
),
},
{
kind: "custom",
name: "timezone",
className: "sm:col-span-2",
render: ({ register, errors }) => (
<Select<ICreateCompanyCredentials>
{...register("timezone")}
errors={errors}
name="timezone"
label="Timezone"
items={timezoneItems}
value={watch("timezone") ?? ""}
placeholder="Select timezone"
searchable
searchPlaceholder="Search timezone..."
clearable
onClear={() => setValue("timezone", "")}
/>
),
},
],
},
{
id: addressSectionId,
title: "Address",
className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2",
fields: [
{
kind: "input",
name: "address.street",
label: "Street",
type: "text",
placeholder: "Building, street, suite or unit",
className: "sm:col-span-2",
rules: {
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
maxLength: { value: 200, message: FormErrorMessages.maxLength(200) },
},
},
{
kind: "input",
name: "address.city",
label: "City",
type: "text",
placeholder: "City or town",
rules: {
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
},
},
{
kind: "input",
name: "address.state",
label: "State",
type: "text",
placeholder: "State, province, or region",
rules: {
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
},
},
{
kind: "input",
name: "address.postal_code",
label: "Postal code",
type: "text",
placeholder: "Postal or ZIP code",
rules: {
minLength: { value: 3, message: FormErrorMessages.minLength(3) },
maxLength: { value: 20, message: FormErrorMessages.maxLength(20) },
},
},
{
kind: "input",
name: "address.country",
label: "Country",
type: "text",
placeholder: "Full country name",
rules: {
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
},
},
],
},
{
id: tagsSectionId,
title: "Tags",
className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2",
fields: [
{
kind: "custom",
name: "tags.keywords",
render: ({ register }) => (
<TagInput<ICreateCompanyCredentials>
{...register("tags.keywords")}
name="tags.keywords"
label="Keywords"
placeholder=""
register={register}
setValue={setValue}
watch={watch}
/>
),
},
{
kind: "custom",
name: "tags.categories",
render: ({ register }) => (
<MultiSelect<ICreateCompanyCredentials>
{...register("tags.categories")}
value={watch("tags.categories")}
name="tags.categories"
label="Categories"
items={categoryItems}
placeholder=""
/>
),
},
{
kind: "custom",
name: "tags.cpv_codes",
render: ({ register }) => (
<TagInput<ICreateCompanyCredentials>
{...register("tags.cpv_codes")}
label="CPV codes"
name="tags.cpv_codes"
shouldAddWithSpace
watch={watch}
setValue={setValue}
placeholder=""
/>
),
},
{
kind: "custom",
name: "tags.certifications",
render: ({ register }) => (
<TagInput<ICreateCompanyCredentials>
{...register("tags.certifications")}
label="Certifications"
name="tags.certifications"
watch={watch}
setValue={setValue}
placeholder=""
/>
),
},
{
kind: "custom",
name: "tags.specializations",
className: "sm:col-span-2",
render: ({ register, error }) => (
<>
<TagInput<ICreateCompanyCredentials>
{...register("tags.specializations")}
label="Specializations"
name="tags.specializations"
watch={watch}
setValue={setValue}
placeholder=""
/>
{error ? (
<p className="mt-1.5 text-sm font-medium text-error">{error}</p>
) : null}
</>
),
},
],
},
{
id: documentsSectionId,
title: "Documents",
rootClassName: "lg:col-span-2",
className: "md:grid-cols-1",
fields: [
{
kind: "custom",
name: "document_file_ids",
render: () => (
<CompanyDocuments
companyId={editMode ? id : undefined}
value={watch("document_file_ids") ?? []}
onChange={(ids) =>
setValue("document_file_ids", ids, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}
onRemovePersist={persistDocumentFileIds}
/>
),
},
],
},
{
id: linksSectionId,
title: "Links",
rootClassName: "lg:col-span-2",
className: "md:grid-cols-1",
fields: [
{
kind: "custom",
name: "links",
render: ({ control, errors, register }) => (
<CompanyLinks control={control} errors={errors} register={register} />
),
},
],
},
];
@@ -8,23 +8,36 @@ interface IProps {
companyId?: string; companyId?: string;
value: string[]; value: string[];
onChange: (ids: string[]) => void; onChange: (ids: string[]) => void;
onRemovePersist?: (ids: string[]) => Promise<void>;
} }
const CompanyDocuments = ({ companyId, value, onChange }: IProps) => { const CompanyDocuments = ({
companyId,
value,
onChange,
onRemovePersist,
}: IProps) => {
const { const {
isUploading, isUploading,
isBusy,
removingFileId,
uploadProgress, uploadProgress,
failed, failed,
errorMessage, errorMessage,
onSelectFiles, onSelectFiles,
onRemove, onRemove,
} = useCompanyDocumentsPresenter({ companyId, value, onChange }); } = useCompanyDocumentsPresenter({
companyId,
value,
onChange,
onRemovePersist,
});
const inputId = "company-documents-input"; const inputId = "company-documents-input";
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="relative overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-white to-primary/[0.04] p-4 shadow-theme-xs dark:border-dark-3 dark:from-dark-2 dark:via-dark-2 dark:to-primary/[0.12]"> <div className="shadow-theme-xs relative overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-white to-primary/[0.04] p-4 dark:border-dark-3 dark:from-dark-2 dark:via-dark-2 dark:to-primary/[0.12]">
<input <input
id={inputId} id={inputId}
type="file" type="file"
@@ -32,14 +45,24 @@ const CompanyDocuments = ({ companyId, value, onChange }: IProps) => {
accept={DOCUMENT_FILE_ACCEPT} accept={DOCUMENT_FILE_ACCEPT}
className="sr-only" className="sr-only"
onChange={onSelectFiles} onChange={onSelectFiles}
disabled={isUploading} disabled={isBusy}
/> />
<label <label
htmlFor={inputId} htmlFor={inputId}
className="group flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-stroke/80 bg-white/70 px-4 py-9 text-center transition-all duration-300 hover:-translate-y-0.5 hover:border-primary hover:bg-primary/[0.03] dark:border-dark-3 dark:bg-dark/20 dark:hover:bg-primary/[0.08]" aria-disabled={isBusy}
className={`group flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-stroke/80 bg-white/70 px-4 py-9 text-center transition-all duration-300 dark:border-dark-3 dark:bg-dark/20 ${
isBusy
? "cursor-not-allowed opacity-60"
: "cursor-pointer hover:-translate-y-0.5 hover:border-primary hover:bg-primary/[0.03] dark:hover:bg-primary/[0.08]"
}`}
>
<div className="shadow-theme-xs mb-3 rounded-2xl bg-primary/[0.12] p-3 text-primary transition-transform duration-300 group-hover:scale-105">
<svg
className="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
<div className="mb-3 rounded-2xl bg-primary/[0.12] p-3 text-primary shadow-theme-xs transition-transform duration-300 group-hover:scale-105">
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -71,6 +94,15 @@ const CompanyDocuments = ({ companyId, value, onChange }: IProps) => {
/> />
</div> </div>
</div> </div>
) : removingFileId ? (
<div className="mt-3">
<p className="text-xs font-medium text-primary">
Removing document
</p>
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-gray-2 dark:bg-dark-3">
<div className="h-full w-full animate-pulse rounded-full bg-gradient-to-r from-primary to-purple-500" />
</div>
</div>
) : null} ) : null}
</div> </div>
@@ -85,7 +117,8 @@ const CompanyDocuments = ({ companyId, value, onChange }: IProps) => {
key={fileId} key={fileId}
fileId={fileId} fileId={fileId}
onRemove={onRemove} onRemove={onRemove}
disabled={isUploading} disabled={isBusy}
isRemoving={removingFileId === fileId}
/> />
))} ))}
</div> </div>
@@ -100,8 +133,12 @@ const CompanyDocuments = ({ companyId, value, onChange }: IProps) => {
</p> </p>
<ul className="space-y-1"> <ul className="space-y-1">
{failed.map((item, index) => ( {failed.map((item, index) => (
<li key={`${item.filename}-${index}`} className="text-sm text-error"> <li
<span className="font-medium">{item.filename}</span>: {item.error} key={`${item.filename}-${index}`}
className="text-sm text-error"
>
<span className="font-medium">{item.filename}</span>:{" "}
{item.error}
</li> </li>
))} ))}
</ul> </ul>
@@ -7,14 +7,19 @@ interface IProps {
/** When provided, a remove button is shown and invoked with the file id. */ /** When provided, a remove button is shown and invoked with the file id. */
onRemove?: (fileId: string) => void; onRemove?: (fileId: string) => void;
disabled?: boolean; disabled?: boolean;
isRemoving?: boolean;
} }
const DocumentItem = ({ fileId, onRemove, disabled }: IProps) => { const DocumentItem = ({ fileId, onRemove, disabled, isRemoving }: IProps) => {
const { info, isLoading, isDownloading, download } = const { info, isLoading, isDownloading, download } =
useDocumentItemPresenter(fileId); useDocumentItemPresenter(fileId);
return ( return (
<div className="flex items-center gap-3 rounded-xl border border-stroke/70 bg-white/70 p-3 shadow-theme-xs dark:border-dark-3 dark:bg-dark/30"> <div
className={`flex items-center gap-3 rounded-xl border border-stroke/70 bg-white/70 p-3 shadow-theme-xs dark:border-dark-3 dark:bg-dark/30 ${
isRemoving ? "opacity-70" : ""
}`}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.12] text-primary"> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.12] text-primary">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
@@ -28,7 +33,7 @@ const DocumentItem = ({ fileId, onRemove, disabled }: IProps) => {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-dark dark:text-white"> <p className="truncate text-sm font-semibold text-dark dark:text-white">
{isLoading ? "Loading…" : info?.filename || "Document"} {isRemoving ? "Removing…" : isLoading ? "Loading…" : info?.filename || "Document"}
</p> </p>
{info?.size ? ( {info?.size ? (
<p className="mt-0.5 text-xs text-dark-5">{formatFileSize(info.size)}</p> <p className="mt-0.5 text-xs text-dark-5">{formatFileSize(info.size)}</p>
@@ -39,7 +44,7 @@ const DocumentItem = ({ fileId, onRemove, disabled }: IProps) => {
<button <button
type="button" type="button"
onClick={download} onClick={download}
disabled={isDownloading} disabled={disabled || isRemoving || isDownloading}
className="rounded-lg border border-stroke/80 bg-white px-3 py-1.5 text-xs font-semibold text-dark shadow-theme-xs transition-all hover:-translate-y-0.5 hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-3 dark:bg-dark-2 dark:text-white" className="rounded-lg border border-stroke/80 bg-white px-3 py-1.5 text-xs font-semibold text-dark shadow-theme-xs transition-all hover:-translate-y-0.5 hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-3 dark:bg-dark-2 dark:text-white"
> >
{isDownloading ? "Downloading…" : "Download"} {isDownloading ? "Downloading…" : "Download"}
@@ -48,10 +53,10 @@ const DocumentItem = ({ fileId, onRemove, disabled }: IProps) => {
<button <button
type="button" type="button"
onClick={() => onRemove(fileId)} onClick={() => onRemove(fileId)}
disabled={disabled} disabled={disabled || isRemoving}
className="rounded-lg border border-error/35 bg-error/5 px-3 py-1.5 text-xs font-semibold text-error transition-all hover:-translate-y-0.5 hover:bg-error/10 disabled:cursor-not-allowed disabled:opacity-60" className="rounded-lg border border-error/35 bg-error/5 px-3 py-1.5 text-xs font-semibold text-error transition-all hover:-translate-y-0.5 hover:bg-error/10 disabled:cursor-not-allowed disabled:opacity-60"
> >
Remove {isRemoving ? "Removing…" : "Remove"}
</button> </button>
) : null} ) : null}
</div> </div>
@@ -1,10 +1,7 @@
"use client"; "use client";
import { useUploadCompanyDocuments } from "@/hooks/queries"; import { useUploadCompanyDocuments } from "@/hooks/queries";
import { fileService, FailedFile } from "@/lib/api"; import { fileService, FailedFile } from "@/lib/api";
import { import { MAX_DOCUMENT_FILES, validateDocumentFile } from "@/utils/shared";
MAX_DOCUMENT_FILES,
validateDocumentFile,
} from "@/utils/shared";
import { ChangeEvent, useState } from "react"; import { ChangeEvent, useState } from "react";
interface IProps { interface IProps {
@@ -14,6 +11,8 @@ interface IProps {
value: string[]; value: string[];
/** Persists the updated document file id list back to the form. */ /** Persists the updated document file id list back to the form. */
onChange: (ids: string[]) => void; onChange: (ids: string[]) => void;
/** Persists document removal for an existing company. */
onRemovePersist?: (ids: string[]) => Promise<void>;
} }
/** /**
@@ -28,12 +27,16 @@ const useCompanyDocumentsPresenter = ({
companyId, companyId,
value, value,
onChange, onChange,
onRemovePersist,
}: IProps) => { }: IProps) => {
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [removingFileId, setRemovingFileId] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [failed, setFailed] = useState<FailedFile[]>([]); const [failed, setFailed] = useState<FailedFile[]>([]);
const [errorMessage, setErrorMessage] = useState<string | undefined>(); const [errorMessage, setErrorMessage] = useState<string | undefined>();
const isBusy = isUploading || removingFileId !== null;
const { mutateAsync: uploadToCompany } = useUploadCompanyDocuments( const { mutateAsync: uploadToCompany } = useUploadCompanyDocuments(
companyId ?? "", companyId ?? "",
); );
@@ -67,6 +70,8 @@ const useCompanyDocumentsPresenter = ({
}; };
const onSelectFiles = async (event: ChangeEvent<HTMLInputElement>) => { const onSelectFiles = async (event: ChangeEvent<HTMLInputElement>) => {
if (isBusy) return;
const input = event.target; const input = event.target;
const files = input.files ? Array.from(input.files) : []; const files = input.files ? Array.from(input.files) : [];
// Reset so selecting the same file again re-triggers change. // Reset so selecting the same file again re-triggers change.
@@ -95,21 +100,36 @@ const useCompanyDocumentsPresenter = ({
}; };
const onRemove = async (fileId: string) => { const onRemove = async (fileId: string) => {
onChange(value.filter((id) => id !== fileId)); if (isBusy) return;
// In create mode the file is an orphan in storage (not linked to any
// company), so clean it up. In edit mode the removal is persisted by the setRemovingFileId(fileId);
// form's update (PUT replaces the full document list). setErrorMessage(undefined);
if (!companyId) {
try { try {
const nextValue = value.filter((id) => id !== fileId);
// In create mode the file is an orphan in storage (not linked to any
// company), so clean it up before dropping it from the form. In edit mode
// persist the replacement document list immediately.
if (!companyId) {
await fileService.deleteFile(fileId); await fileService.deleteFile(fileId);
} catch (error) { } else {
console.error("ERROR caught while deleting orphan document:", error); await onRemovePersist?.(nextValue);
} }
onChange(nextValue);
} catch (error) {
console.error("ERROR caught while deleting company document:", error);
setErrorMessage("Failed to remove document. Please try again.");
} finally {
setRemovingFileId(null);
} }
}; };
return { return {
isUploading, isUploading,
isBusy,
removingFileId,
uploadProgress, uploadProgress,
failed, failed,
errorMessage, errorMessage,
@@ -0,0 +1,110 @@
"use client";
import InputGroup from "@/components/FormElements/InputGroup";
import type { ICreateCompanyCredentials } from "@/lib/api";
import type { Control, FieldErrors, UseFormRegister } from "react-hook-form";
import { get, useFieldArray } from "react-hook-form";
const MAX_COMPANY_LINKS = 20;
interface CompanyLinksProps {
control: Control<ICreateCompanyCredentials>;
register: UseFormRegister<ICreateCompanyCredentials>;
errors: FieldErrors<ICreateCompanyCredentials>;
}
const validateHttpsUrl = (value?: string) => {
const trimmedValue = value?.trim() ?? "";
if (!trimmedValue) return "URL is required";
try {
const url = new URL(trimmedValue);
return url.protocol === "https:" || "Enter a valid HTTPS URL";
} catch {
return "Enter a valid HTTPS URL";
}
};
const CompanyLinks = ({ control, register, errors }: CompanyLinksProps) => {
const { fields, append, remove } = useFieldArray({
control,
name: "links",
});
return (
<div className="space-y-4">
{fields.length === 0 ? (
<p className="text-sm text-dark-5 dark:text-dark-6">
No external links added yet.
</p>
) : null}
{fields.map((field, index) => {
const urlName = `links.${index}.url` as const;
const titleName = `links.${index}.title` as const;
return (
<div
key={field.id}
className="grid gap-4 rounded-xl border border-stroke/70 bg-gray-1/50 p-4 dark:border-dark-3 dark:bg-dark-2/50 sm:grid-cols-2"
>
<InputGroup<ICreateCompanyCredentials>
{...register(titleName, {
setValueAs: (value: string) => value.trim(),
maxLength: {
value: 200,
message: "Title must be 200 characters or fewer",
},
})}
name={titleName}
label="Title (optional)"
type="text"
placeholder="LinkedIn"
errors={errors}
/>
<InputGroup<ICreateCompanyCredentials>
{...register(urlName, {
validate: validateHttpsUrl,
setValueAs: (value: string) => value.trim(),
})}
name={urlName}
label="URL"
type="url"
placeholder="https://example.com/profile"
required
errors={errors}
/>
<div className="flex items-center justify-between sm:col-span-2">
<span className="text-xs text-error">
{get(errors, `links.${index}.root`)?.message ?? ""}
</span>
<button
type="button"
onClick={() => remove(index)}
className="rounded-lg border border-error/30 px-3 py-1.5 text-sm font-medium text-error transition hover:bg-error/10"
>
Remove
</button>
</div>
</div>
);
})}
<div className="flex items-center justify-between gap-4">
<p className="text-xs text-dark-5 dark:text-dark-6">
{fields.length}/{MAX_COMPANY_LINKS} links
</p>
<button
type="button"
disabled={fields.length >= MAX_COMPANY_LINKS}
onClick={() => append({ url: "", title: "" })}
className="rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
+ Add link
</button>
</div>
</div>
);
};
export default CompanyLinks;
@@ -20,6 +20,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
const isMutating = useIsMutating(); const isMutating = useIsMutating();
const { const {
register, register,
control,
formState: { errors }, formState: { errors },
handleSubmit, handleSubmit,
watch, watch,
@@ -29,6 +30,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
mode: "onChange", mode: "onChange",
defaultValues: { defaultValues: {
...defaultValues, ...defaultValues,
links: defaultValues?.links ?? [],
}, },
}); });
@@ -41,23 +43,43 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
} }
}, [defaultValues, setValue]); }, [defaultValues, setValue]);
const router = useRouter(); const router = useRouter();
const { data: companyCategories } = useCompanyCategoriesQuery({ published: true }); const { data: companyCategories } = useCompanyCategoriesQuery({
published: true,
});
const { mutate: createCompany, isPending: isCreating } = useCreateCompany(); const { mutate: createCompany, isPending: isCreating } = useCreateCompany();
const { mutate: updateCompany, isPending: isUpdating } = useUpdateCompany( const { mutate: updateCompany, isPending: isUpdating } = useUpdateCompany(
id as string, id as string,
); );
const onSubmit: SubmitHandler<ICreateCompanyCredentials> = (data) => { const { mutateAsync: updateCompanyDocuments } = useUpdateCompany(
const formattedData = { id as string,
{
redirectOnSuccess: false,
showSuccessToast: false,
},
);
const optionalNumber = (value: unknown) => {
if (value === "" || value === null || value === undefined) return undefined;
return Number(value);
};
const formatCompanyData = (
data: ICreateCompanyCredentials,
documentFileIds = data.document_file_ids,
) => ({
...data, ...data,
founded_year: data.founded_year ? +data.founded_year : 0, founded_year: optionalNumber(data.founded_year),
employee_count: data.employee_count ? +data.employee_count : 0, employee_count: optionalNumber(data.employee_count),
annual_revenue: data.annual_revenue ? +data.annual_revenue : 0, annual_revenue: optionalNumber(data.annual_revenue),
document_file_ids: data.document_file_ids ?? [], document_file_ids: documentFileIds ?? [],
links: (data.links ?? []).map((link) => ({
url: link.url.trim(),
...(link.title?.trim() ? { title: link.title.trim() } : {}),
})),
tags: { tags: {
keywords: data?.tags?.keywords?.length ? data?.tags?.keywords : [], keywords: data?.tags?.keywords?.length ? data?.tags?.keywords : [],
categories: data?.tags?.categories?.length categories: data?.tags?.categories?.length ? data?.tags?.categories : [],
? data?.tags?.categories
: [],
cpv_codes: data?.tags?.cpv_codes?.length ? data?.tags?.cpv_codes : [], cpv_codes: data?.tags?.cpv_codes?.length ? data?.tags?.cpv_codes : [],
specializations: data?.tags?.specializations?.length specializations: data?.tags?.specializations?.length
? data?.tags?.specializations ? data?.tags?.specializations
@@ -66,7 +88,10 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
? data?.tags?.certifications ? data?.tags?.certifications
: [], : [],
}, },
}; });
const onSubmit: SubmitHandler<ICreateCompanyCredentials> = (data) => {
const formattedData = formatCompanyData(data);
if (editMode) { if (editMode) {
updateCompany({ updateCompany({
id: id as string, id: id as string,
@@ -75,7 +100,18 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
} else { } else {
createCompany(formattedData as ICreateCompanyCredentials); createCompany(formattedData as ICreateCompanyCredentials);
} }
};
const persistDocumentFileIds = async (documentFileIds: string[]) => {
if (!editMode || !id) return;
await updateCompanyDocuments({
id,
credentials: formatCompanyData(
watch() as ICreateCompanyCredentials,
documentFileIds,
) as ICreateCompanyCredentials,
});
}; };
const handleCancel = () => { const handleCancel = () => {
@@ -85,6 +121,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
return { return {
errors, errors,
register, register,
control,
handleSubmit, handleSubmit,
onSubmit, onSubmit,
handleCancel, handleCancel,
@@ -94,6 +131,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
router, router,
watch, watch,
setValue, setValue,
persistDocumentFileIds,
companyCategories, companyCategories,
}; };
}; };
+21 -207
View File
@@ -1,21 +1,9 @@
"use client"; "use client";
import { EyeIcon, GlobeIcon } from "@/assets/icons"; import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
import InputGroup from "@/components/FormElements/InputGroup";
import MultiSelect from "@/components/FormElements/MultiSelect";
import {
createPhoneFieldRules,
PhoneNumberInput,
} from "@/components/FormElements/PhoneNumberInput";
import { Select } from "@/components/FormElements/select";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import FormFooter from "@/components/ui/FormFooter"; import FormFooter from "@/components/ui/FormFooter";
import { AdminRoles } from "@/constants/enums"; import type { CreateCustomerCredentials } from "@/lib/api";
import { REGEX } from "@/constants/regex"; import { createCustomerFormSections } from "./customerFormSections";
import { FormErrorMessages } from "@/constants/Texts";
import { CreateCustomerCredentials } from "@/lib/api";
import { getEnumAsArray } from "@/utils/shared";
import { Controller } from "react-hook-form";
import useCreateCustomerPresenter from "./useCreateCustomerPresenter"; import useCreateCustomerPresenter from "./useCreateCustomerPresenter";
interface IProps { interface IProps {
@@ -37,202 +25,28 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
setShowPassword, setShowPassword,
} = useCreateCustomerPresenter({ defaultValues, editMode, id }); } = useCreateCustomerPresenter({ defaultValues, editMode, id });
const companyItems =
companies?.map((company) => ({
value: company.id,
label: company.name,
})) ?? [];
return ( return (
<form className="grid grid-cols-1 gap-9" onSubmit={handleSubmit(onSubmit)}> <DynamicFormBuilder<CreateCustomerCredentials>
<ShowcaseSection sections={createCustomerFormSections({
title="Create Customer" editMode,
className="grid grid-cols-1 gap-5 md:grid-cols-2" companyItems,
> watch,
<div className="flex flex-col gap-2"> showPassword,
<InputGroup setShowPassword,
{...register("username", {
required: { value: true, message: FormErrorMessages.required },
minLength: { value: 3, message: FormErrorMessages.minLength(3) },
maxLength: {
value: 30,
message: FormErrorMessages.maxLength(30),
},
pattern: {
value: REGEX.USERNAME,
message: FormErrorMessages.invalidPattern,
},
})} })}
name="username" register={register}
label="Username"
type="text"
placeholder="Unique login, letters and numbers only"
required
/>
{errors.username && (
<p className="mt-1 text-sm text-red-500">
{errors.username.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<MultiSelect
label="Select companies"
value={watch("companies")}
items={
companies
? companies?.map((item) => ({
value: item.id,
label: item.name,
}))
: []
}
{...register("companies")}
errors={errors}
placeholder=""
/>
</div>
<div className="flex flex-col gap-2">
<InputGroup
{...register("email", {
required: { value: true, message: FormErrorMessages.required },
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: FormErrorMessages.invalidEmail,
},
})}
name="email"
label="Email"
type="email"
required
placeholder="name@company.com"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500">{errors.email.message}</p>
)}
</div>
<div className="flex flex-col">
<Select
label="Role"
{...register("role", {
required: { message: FormErrorMessages.required, value: true },
})}
name="role"
value={watch("role")}
items={getEnumAsArray(AdminRoles)}
placeholder="Choose a role"
required
/>
</div>
{!editMode && (
<div className="flex flex-col gap-2">
<InputGroup
{...register("password", {
required: { value: true, message: FormErrorMessages.required },
minLength: {
value: 8,
message: FormErrorMessages.minLength(8),
},
maxLength: {
value: 128,
message: FormErrorMessages.maxLength(128),
},
})}
disabled={editMode}
name="password"
icon={
<button
className="absolute right-4.5 top-1/2 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-dark-5 transition hover:text-primary dark:text-white/75 [&>svg]:!static [&>svg]:!size-5 [&>svg]:!translate-y-0"
onClick={() => {
setShowPassword(!showPassword);
}}
>
<EyeIcon />
</button>
}
iconPosition="right"
label="Password"
type={showPassword ? "text" : "password"}
placeholder="At least 8 characters"
required={!editMode}
/>
{errors.password && (
<p className="mt-1 text-sm text-red-500">
{errors.password.message}
</p>
)}
</div>
)}
<div className="flex flex-col gap-2">
<InputGroup
{...register("full_name", {
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
name="full_name"
label="Full name"
type="text"
/>
{errors.full_name && (
<p className="mt-1 text-sm text-red-500">
{errors.full_name.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Controller
name="phone"
control={control} control={control}
rules={createPhoneFieldRules({ required: false })} errors={errors}
render={({ field }) => ( handleSubmit={handleSubmit}
<PhoneNumberInput onSubmit={onSubmit}
label="Phone" footer={<FormFooter />}
value={field.value ?? ""}
onChange={field.onChange}
onBlur={field.onBlur}
error={errors.phone?.message as string | undefined}
/> />
)}
/>
</div>
<div className="flex flex-col gap-2">
<Select
{...register("type", {
required: {
message: FormErrorMessages.required,
value: true,
},
})}
name="type"
label="Type"
required
value={watch("type")}
placeholder="Choose customer type"
prefixIcon={<GlobeIcon />}
items={[
{
label: "Individual",
value: "individual",
},
{
label: "Company",
value: "company",
},
{
label: "Government",
value: "government",
},
]}
/>
{errors.type && (
<p className="mt-1 text-sm text-red-500">{errors.type.message}</p>
)}
</div>
<div className="md:col-span-2">
<FormFooter />
</div>
</ShowcaseSection>
</form>
); );
}; };
@@ -0,0 +1,189 @@
import { EyeIcon, GlobeIcon } from "@/assets/icons";
import MultiSelect from "@/components/FormElements/MultiSelect";
import {
createPhoneFieldRules,
PhoneNumberInput,
} from "@/components/FormElements/PhoneNumberInput";
import { Select } from "@/components/FormElements/select";
import type { DynamicFormSection } from "@/components/forms/DynamicFormBuilder";
import { AdminRoles } from "@/constants/enums";
import { REGEX } from "@/constants/regex";
import { FormErrorMessages } from "@/constants/Texts";
import type { CreateCustomerCredentials } from "@/lib/api";
import type { ILabelValue } from "@/types/shared";
import { getEnumAsArray } from "@/utils/shared";
import type { Dispatch, SetStateAction } from "react";
import { Controller, type UseFormWatch } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
const customerSectionId = uuidv4();
interface CustomerFormSectionsOptions {
editMode?: boolean;
companyItems: ILabelValue[];
watch: UseFormWatch<CreateCustomerCredentials>;
showPassword: boolean;
setShowPassword: Dispatch<SetStateAction<boolean>>;
}
export const createCustomerFormSections = ({
editMode,
companyItems,
watch,
showPassword,
setShowPassword,
}: CustomerFormSectionsOptions): DynamicFormSection<CreateCustomerCredentials>[] => [
{
id: customerSectionId,
title: "Create Customer",
className: "gap-5",
fields: [
{
kind: "input",
name: "username",
label: "Username",
type: "text",
placeholder: "Unique login, letters and numbers only",
required: true,
rules: {
required: { value: true, message: FormErrorMessages.required },
minLength: { value: 3, message: FormErrorMessages.minLength(3) },
maxLength: { value: 30, message: FormErrorMessages.maxLength(30) },
pattern: {
value: REGEX.USERNAME,
message: FormErrorMessages.invalidPattern,
},
},
},
{
kind: "custom",
name: "companies",
render: ({ register, errors }) => (
<MultiSelect<CreateCustomerCredentials>
label="Select companies"
value={watch("companies")}
items={companyItems}
{...register("companies")}
errors={errors}
placeholder=""
/>
),
},
{
kind: "input",
name: "email",
label: "Email",
type: "email",
placeholder: "name@company.com",
required: true,
rules: {
required: { value: true, message: FormErrorMessages.required },
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: FormErrorMessages.invalidEmail,
},
},
},
{
kind: "custom",
name: "role",
render: ({ register, errors }) => (
<Select<CreateCustomerCredentials>
label="Role"
{...register("role", {
required: { message: FormErrorMessages.required, value: true },
})}
errors={errors}
name="role"
value={watch("role")}
items={getEnumAsArray(AdminRoles)}
placeholder="Choose a role"
required
/>
),
},
{
kind: "input",
name: "password",
label: "Password",
type: showPassword ? "text" : "password",
placeholder: "At least 8 characters",
required: !editMode,
disabled: editMode,
hidden: editMode,
iconPosition: "right",
icon: (
<button
type="button"
className="absolute right-4.5 top-1/2 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-dark-5 transition hover:text-primary dark:text-white/75 [&>svg]:!static [&>svg]:!size-5 [&>svg]:!translate-y-0"
onClick={() => setShowPassword((visible) => !visible)}
aria-label={showPassword ? "Hide password" : "Show password"}
>
<EyeIcon />
</button>
),
rules: {
required: { value: true, message: FormErrorMessages.required },
minLength: { value: 8, message: FormErrorMessages.minLength(8) },
maxLength: { value: 128, message: FormErrorMessages.maxLength(128) },
},
},
{
kind: "input",
name: "full_name",
label: "Full name",
type: "text",
rules: {
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
},
},
{
kind: "custom",
name: "phone",
render: ({ control, errors }) => (
<Controller
name="phone"
control={control}
rules={createPhoneFieldRules({ required: false })}
render={({ field }) => (
<PhoneNumberInput
label="Phone"
value={field.value ?? ""}
onChange={field.onChange}
onBlur={field.onBlur}
error={errors.phone?.message as string | undefined}
/>
)}
/>
),
},
{
kind: "custom",
name: "type",
render: ({ register, errors }) => (
<Select<CreateCustomerCredentials>
{...register("type", {
required: {
message: FormErrorMessages.required,
value: true,
},
})}
errors={errors}
name="type"
label="Type"
required
value={watch("type")}
placeholder="Choose customer type"
prefixIcon={<GlobeIcon />}
items={[
{ label: "Individual", value: "individual" },
{ label: "Company", value: "company" },
{ label: "Government", value: "government" },
]}
/>
),
},
],
},
];
@@ -1,13 +1,9 @@
"use client"; "use client";
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
import InputGroup from "@/components/FormElements/InputGroup"; import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
import MultiSelect from "@/components/FormElements/MultiSelect";
import { Select } from "@/components/FormElements/select";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import FormFooter from "@/components/ui/FormFooter"; import FormFooter from "@/components/ui/FormFooter";
import { REGEX } from "@/constants/regex"; import type { TCreateNotificationCredentials } from "@/lib/api/types/NotificationHistory";
import { FormErrorMessages } from "@/constants/Texts"; import { createNotificationFormSections } from "./notificationFormSections";
import useCreateNotificationFormPresenter from "./useCreateNotificationFormPresenter"; import useCreateNotificationFormPresenter from "./useCreateNotificationFormPresenter";
const CreateNotificationForm = () => { const CreateNotificationForm = () => {
@@ -29,157 +25,29 @@ const CreateNotificationForm = () => {
types, types,
channels, channels,
} = useCreateNotificationFormPresenter(); } = useCreateNotificationFormPresenter();
return (
<form onSubmit={handleSubmit(onSubmit)}>
<ShowcaseSection
title="Create Notification"
className="grid grid-cols-1 gap-5 md:grid-cols-2"
>
<div className="flex flex-col gap-2">
<InputGroup
required
{...register("title", {
required: { value: true, message: FormErrorMessages.required },
})}
name="title"
label="Title"
type="text"
placeholder="Short headline shown to recipients"
/>
{errors.title && (
<p className="mt-1 text-sm text-red-500">{errors.title.message}</p>
)}
</div>
<div className="flex flex-col gap-2">
<DatePicker
control={control}
name="schedule_at"
label="schedule at"
range={false}
timePicker
/>
</div>
<div className="flex flex-col gap-2">
<Select
required
{...register("target", {
required: {
value: true,
message: FormErrorMessages.required,
},
onChange: onSelectTarget,
})}
items={targets}
label="target"
placeholder="Choose who this applies to"
name="target"
/>
{errors.target && (
<p className="mt-1 text-sm text-red-500">{errors.target.message}</p>
)}
</div>
<div className="flex flex-col gap-2"> return (
<MultiSelect <DynamicFormBuilder<TCreateNotificationCredentials>
disabled={!recipient} sections={createNotificationFormSections({
required={!!recipient} recipient,
{...register("recipient")} onSelectTarget,
label="recipient" onSearchRecipient,
items={recipient ?? []} onLoadMoreRecipients,
placeholder={ hasMoreRecipients,
recipient && recipient.length > 0 ? "Choose recipients" : "" isLoadingMoreRecipients,
} isLoadingRecipients,
name="recipient" priorities,
onSearch={recipient ? onSearchRecipient : undefined} targets,
onLoadMore={onLoadMoreRecipients} types,
hasMore={hasMoreRecipients} channels,
isLoading={isLoadingRecipients}
isLoadingMore={isLoadingMoreRecipients}
/>
</div>
<div className="flex flex-col gap-2">
<MultiSelect
required
{...register("channels")}
label="channels"
items={channels ?? []}
placeholder="Email, push, or both"
name="channels"
/>
</div>
<div className="flex flex-col gap-2">
<InputGroup
{...register("link", {
pattern: {
value: REGEX.URL,
message: FormErrorMessages.invalidPattern,
},
})} })}
name="link" register={register}
label="Link" control={control}
type="url" errors={errors}
handleSubmit={handleSubmit}
onSubmit={onSubmit}
footer={<FormFooter />}
/> />
{errors.link && (
<p className="mt-1 text-sm text-red-500">{errors.link.message}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Select
required
{...register("priority", {
required: {
value: true,
message: FormErrorMessages.required,
},
})}
items={priorities}
label="Priority"
placeholder="Choose urgency"
name="priority"
/>
{errors.priority && (
<p className="mt-1 text-sm text-red-500">
{errors.priority.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Select
required
{...register("type", {
required: {
value: true,
message: FormErrorMessages.required,
},
})}
items={types}
label="type"
placeholder="Info, warning, alert, or reject"
name="type"
/>
{errors.type && (
<p className="mt-1 text-sm text-red-500">{errors.type.message}</p>
)}
</div>
<div className="flex flex-col gap-2 md:col-span-2">
<TextAreaGroup
required
{...register("description", {
required: {
value: true,
message: FormErrorMessages.required,
},
})}
label="Description"
name="description"
placeholder="Main message — include context and any action needed"
/>
</div>
<div className="md:col-span-2">
<FormFooter />
</div>
</ShowcaseSection>
</form>
); );
}; };
@@ -0,0 +1,204 @@
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
import MultiSelect from "@/components/FormElements/MultiSelect";
import { Select } from "@/components/FormElements/select";
import type { DynamicFormSection } from "@/components/forms/DynamicFormBuilder";
import { REGEX } from "@/constants/regex";
import { FormErrorMessages } from "@/constants/Texts";
import type { TCreateNotificationCredentials } from "@/lib/api/types/NotificationHistory";
import type { ILabelValue } from "@/types/shared";
import type { ChangeEvent } from "react";
import { v4 as uuidv4 } from "uuid";
const notificationSectionId = uuidv4();
interface NotificationFormSectionsOptions {
recipient: ILabelValue[] | null;
onSelectTarget: (event: ChangeEvent<HTMLSelectElement>) => void;
onSearchRecipient: (query: string) => void;
onLoadMoreRecipients: () => void;
hasMoreRecipients: boolean;
isLoadingMoreRecipients: boolean;
isLoadingRecipients: boolean;
priorities: ILabelValue[];
targets: ILabelValue[];
types: ILabelValue[];
channels: ILabelValue[];
}
export const createNotificationFormSections = ({
recipient,
onSelectTarget,
onSearchRecipient,
onLoadMoreRecipients,
hasMoreRecipients,
isLoadingMoreRecipients,
isLoadingRecipients,
priorities,
targets,
types,
channels,
}: NotificationFormSectionsOptions): DynamicFormSection<TCreateNotificationCredentials>[] => [
{
id: notificationSectionId,
title: "Create Notification",
className: "gap-5",
fields: [
{
kind: "input",
name: "title",
label: "Title",
type: "text",
placeholder: "Short headline shown to recipients",
required: true,
rules: {
required: { value: true, message: FormErrorMessages.required },
},
},
{
kind: "custom",
name: "schedule_at",
render: ({ control }) => (
<DatePicker
control={control}
name="schedule_at"
label="schedule at"
range={false}
timePicker
/>
),
},
{
kind: "custom",
name: "target",
render: ({ register, errors }) => (
<Select<TCreateNotificationCredentials>
required
{...register("target", {
required: {
value: true,
message: FormErrorMessages.required,
},
onChange: onSelectTarget,
})}
errors={errors}
items={targets}
label="target"
placeholder="Choose who this applies to"
name="target"
/>
),
},
{
kind: "custom",
name: "recipient",
render: ({ register, errors }) => (
<MultiSelect<TCreateNotificationCredentials>
disabled={!recipient}
required={!!recipient}
{...register("recipient")}
errors={errors}
label="recipient"
items={recipient ?? []}
placeholder={
recipient && recipient.length > 0 ? "Choose recipients" : ""
}
name="recipient"
onSearch={recipient ? onSearchRecipient : undefined}
onLoadMore={onLoadMoreRecipients}
hasMore={hasMoreRecipients}
isLoading={isLoadingRecipients}
isLoadingMore={isLoadingMoreRecipients}
/>
),
},
{
kind: "custom",
name: "channels",
render: ({ register, errors }) => (
<MultiSelect<TCreateNotificationCredentials>
required
{...register("channels")}
errors={errors}
label="channels"
items={channels ?? []}
placeholder="Email, push, or both"
name="channels"
/>
),
},
{
kind: "input",
name: "link",
label: "Link",
type: "url",
rules: {
pattern: {
value: REGEX.URL,
message: FormErrorMessages.invalidPattern,
},
},
},
{
kind: "custom",
name: "priority",
render: ({ register, errors }) => (
<Select<TCreateNotificationCredentials>
required
{...register("priority", {
required: {
value: true,
message: FormErrorMessages.required,
},
})}
errors={errors}
items={priorities}
label="Priority"
placeholder="Choose urgency"
name="priority"
/>
),
},
{
kind: "custom",
name: "type",
render: ({ register, errors }) => (
<Select<TCreateNotificationCredentials>
required
{...register("type", {
required: {
value: true,
message: FormErrorMessages.required,
},
})}
errors={errors}
items={types}
label="type"
placeholder="Info, warning, alert, or reject"
name="type"
/>
),
},
{
kind: "custom",
name: "description",
className: "md:col-span-2",
render: ({ register, errors }) => (
<TextAreaGroup<TCreateNotificationCredentials>
required
{...register("description", {
required: {
value: true,
message: FormErrorMessages.required,
},
})}
errors={errors}
label="Description"
name="description"
placeholder="Main message — include context and any action needed"
/>
),
},
],
},
];
+3 -2
View File
@@ -4,8 +4,9 @@ export function Logo() {
return ( return (
<Image <Image
src="/images/main-logo.svg" src="/images/main-logo.svg"
width={174} width={1217}
height={32} height={192}
className="h-auto w-[174px]"
alt="Opp lens logo" alt="Opp lens logo"
role="presentation" role="presentation"
quality={100} quality={100}
+46
View File
@@ -0,0 +1,46 @@
export { default as Boolean } from "./Boolean";
export { default as CollapsibleFilterSection } from "./CollapsibleFilterSection";
export {
default as ConfirmationModal,
type ConfirmationModalProps,
} from "./ConfirmationModal";
export {
Dropdown,
DropdownClose,
DropdownContent,
DropdownTrigger,
} from "./dropdown";
export { default as FileImageUploadCard } from "./FileImageUploadCard";
export { default as FormFooter } from "./FormFooter";
export { default as ImageUploadField } from "./ImageUploadField";
export {
default as InlineListFiltersPanel,
type InlineListFiltersPanelProps,
} from "./InlineListFiltersPanel";
export { default as IsVisible } from "./IsVisible";
export { default as ListHeader } from "./ListHeader";
export { default as ListWrapper } from "./ListWrapper";
export { default as Modal } from "./modal";
export { NotificationDetailsCard } from "./notification-details-card";
export { default as Pagination } from "./pagination";
export { default as ResetPasswordModalContent } from "./ResetPasswordModalContent";
export { default as ShadowBox } from "./shadow-box";
export { Skeleton } from "./skeleton";
export { default as Status } from "./Status";
export { default as Stepper, type Step } from "./Stepper";
export {
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
TableSortProvider,
} from "./table";
export { default as TableSkeleton } from "./TableSkeleton";
export {
TableSortContext,
type SortDirection,
type TableSortContextValue,
} from "./tableSortContext";
+13 -1
View File
@@ -25,6 +25,7 @@ interface UserContextType {
setAuthState: (data: ILoginResponse) => void; setAuthState: (data: ILoginResponse) => void;
logout: () => void; logout: () => void;
setUser: Dispatch<SetStateAction<IUser | null>>; setUser: Dispatch<SetStateAction<IUser | null>>;
syncCurrentUser: (updates: Partial<IUser>) => void;
} }
const UserContext = createContext<UserContextType | undefined>(undefined); const UserContext = createContext<UserContextType | undefined>(undefined);
@@ -99,6 +100,16 @@ export const UserProvider: React.FC<UserProviderProps> = ({ children }) => {
Cookies.set(COOKIE_KEYS.user, JSON.stringify(data.user)); Cookies.set(COOKIE_KEYS.user, JSON.stringify(data.user));
}, []); }, []);
const syncCurrentUser = useCallback((updates: Partial<IUser>) => {
setUser((prev) => {
if (!prev) return prev;
const next = { ...prev, ...updates };
Cookies.set(COOKIE_KEYS.user, JSON.stringify(next));
return next;
});
}, []);
const value = useMemo( const value = useMemo(
() => ({ () => ({
user, user,
@@ -108,8 +119,9 @@ export const UserProvider: React.FC<UserProviderProps> = ({ children }) => {
setAuthState, setAuthState,
logout, logout,
setUser, setUser,
syncCurrentUser,
}), }),
[user, accessToken, isLoading, setAuthState, logout, setUser], [user, accessToken, isLoading, setAuthState, logout, setUser, syncCurrentUser],
); );
return <UserContext.Provider value={value}>{children}</UserContext.Provider>; return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
+1 -1
View File
@@ -13,7 +13,7 @@ export * from "./useNotificationQueries";
export * from "./useProfileQueries"; export * from "./useProfileQueries";
export * from "./useTenders"; export * from "./useTenders";
export * from "./useUsersQueries"; export * from "./useUsersQueries";
export * from "./useAdminListQuery"; export * from "./useAdminQueries";
export * from "./useSelectSearchQuery"; export * from "./useSelectSearchQuery";
export * from "./useDashboardQueries"; export * from "./useDashboardQueries";
export * from "./useFilesQueries"; export * from "./useFilesQueries";
-11
View File
@@ -1,11 +0,0 @@
import { API_ENDPOINTS, userService } from "@/lib/api";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
export const useGetAdminListQuery = (params?: Record<string, any>) => {
const queryKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, params], [params]);
return useQuery({
queryKey,
queryFn: () => userService.adminsList(params),
});
};
+174
View File
@@ -0,0 +1,174 @@
import { useUser } from "@/contexts";
import { API_ENDPOINTS, ICreateAdminCredentials, userService } from "@/lib/api";
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { toast } from "react-toastify";
const toCurrentUserUpdates = ({
password: _password,
...profile
}: ICreateAdminCredentials) => profile;
export const useDeleteAdmin = (onSuccessCallback: () => void) => {
const queryClient = useQueryClient();
const mutationKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, "delete"], []);
return useMutation({
mutationKey,
mutationFn: userService.deleteAdmin,
onSuccess: (response) => {
toast.info(response.data.message);
onSuccessCallback();
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.USER.ADMINS],
});
},
});
};
export const useCreateAdmin = () => {
const queryClient = useQueryClient();
const router = useRouter();
const mutationKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, "create"], []);
return useMutation({
mutationKey,
mutationFn: userService.createAdmin,
onSuccess: (response) => {
toast.success(response);
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.USER.ADMINS],
});
router.push("/admins");
},
});
};
export const useUpdateAdmin = (id: string) => {
const queryClient = useQueryClient();
const router = useRouter();
const { user, syncCurrentUser } = useUser();
const mutationKey = useMemo(
() => [API_ENDPOINTS.USER.ADMINS, "update", id],
[id],
);
return useMutation({
mutationKey,
mutationFn: userService.updateAdmin,
onSuccess: (response, { id: adminId, credentials }) => {
if (user?.id === adminId) {
syncCurrentUser(toCurrentUserUpdates(credentials));
}
toast.success(response);
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.USER.ADMINS],
});
router.push("/admins");
},
});
};
export const useAdminDetails = (id: string) => {
const queryKey = useMemo(
() => [API_ENDPOINTS.USER.ADMINS, "details", id],
[id],
);
return useQuery({
queryKey,
queryFn: () => userService.adminDetails(id),
});
};
export const useAdminListInfiniteQuery = (
params?: Record<string, any>,
options?: { enabled?: boolean },
) => {
const enabled = options?.enabled ?? true;
const queryKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, params], [params]);
return useInfiniteQuery({
queryKey,
queryFn: ({ pageParam = 0 }) =>
userService.adminsList({ ...params, offset: pageParam }),
initialPageParam: 0,
enabled,
getNextPageParam: (lastPage) => {
if (!lastPage.meta) {
return undefined;
}
const { offset, limit, total } = lastPage.meta;
const nextOffset = offset + limit;
return nextOffset < total ? nextOffset : undefined;
},
});
};
export const useGetAdminListQuery = (params?: Record<string, any>) => {
const queryKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, params], [params]);
return useQuery({
queryKey,
queryFn: () => userService.adminsList(params),
});
};
export const useGetUsersQuery = (params?: Record<string, any>) => {
const queryKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, params], [params]);
return useQuery({
queryKey,
queryFn: () => userService.adminsList(params),
});
};
export const useResetAdminPassword = ({
successCallback,
}: {
successCallback?: (password: string) => void;
} = {}) => {
const mutationKey = useMemo(
() => [API_ENDPOINTS.USER.ADMINS, "reset-password"],
[],
);
return useMutation({
mutationKey,
mutationFn: userService.resetAdminPassword,
onSuccess: (response) => {
toast.success(response.message);
successCallback?.(response.data.password);
},
});
};
export const useChangeAdminStatus = ({
id,
successCallback,
}: {
id: string;
successCallback: () => void;
}) => {
const queryClient = useQueryClient();
const mutationKey = useMemo(
() => [API_ENDPOINTS.USER.CHANGE_ADMIN_STATUS(id)],
[id],
);
return useMutation({
mutationKey,
mutationFn: userService.changeAdminStatus,
onSuccess: ({ message }: { message: string }) => {
successCallback();
toast.success(message);
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.USER.ADMINS],
});
},
});
};
+46 -5
View File
@@ -2,6 +2,7 @@ import {
API_ENDPOINTS, API_ENDPOINTS,
companiesService, companiesService,
companyCategoriesService, companyCategoriesService,
type ICompanyLink,
type TCompanyCategoryApiResponse, type TCompanyCategoryApiResponse,
} from "@/lib/api"; } from "@/lib/api";
import { import {
@@ -31,7 +32,18 @@ export const useCreateCompany = () => {
}); });
}; };
export const useUpdateCompany = (id: string) => { interface UseUpdateCompanyOptions {
redirectOnSuccess?: boolean;
showSuccessToast?: boolean;
}
export const useUpdateCompany = (
id: string,
{
redirectOnSuccess = true,
showSuccessToast = true,
}: UseUpdateCompanyOptions = {},
) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter(); const router = useRouter();
const mutationKey = useMemo( const mutationKey = useMemo(
@@ -42,14 +54,21 @@ export const useUpdateCompany = (id: string) => {
mutationKey, mutationKey,
mutationFn: companiesService.updateCompany, mutationFn: companiesService.updateCompany,
onSuccess: () => { onSuccess: () => {
if (showSuccessToast) {
toast.success("Company updated successfully"); toast.success("Company updated successfully");
}
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL], queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL],
}); });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL, "details"], queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL, "details"],
}); });
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL, id, "details"],
});
if (redirectOnSuccess) {
router.push("/companies"); router.push("/companies");
}
}, },
}); });
}; };
@@ -82,6 +101,26 @@ export const useUploadCompanyDocuments = (id: string) => {
}); });
}; };
export const useAddCompanyLinks = (id: string) => {
const queryClient = useQueryClient();
const mutationKey = useMemo(
() => [API_ENDPOINTS.COMPANIES.LINKS(id), "add-links"],
[id],
);
return useMutation({
mutationKey,
mutationFn: (links: ICompanyLink[]) =>
companiesService.addLinks({ id, links }),
onSuccess: (response) => {
toast.success(response.message ?? "Company links added successfully");
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL, id, "details"],
});
},
});
};
export const useDeleteCompanyQuery = (successCallback?: () => void) => { export const useDeleteCompanyQuery = (successCallback?: () => void) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutationKey = useMemo( const mutationKey = useMemo(
@@ -206,7 +245,8 @@ export const useDeleteCompanyCategoryQuery = (successCallback?: () => void) => {
}; };
export const useToggleCompanyCategoryPublished = () => { export const useToggleCompanyCategoryPublished = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const updateOptimisticCategories = useCallback((categoryId: string) => { const updateOptimisticCategories = useCallback(
(categoryId: string) => {
queryClient.setQueriesData<TCompanyCategoryApiResponse>( queryClient.setQueriesData<TCompanyCategoryApiResponse>(
{ {
queryKey: [API_ENDPOINTS.COMPANIES.CATEGORIES.READ_ALL], queryKey: [API_ENDPOINTS.COMPANIES.CATEGORIES.READ_ALL],
@@ -229,7 +269,9 @@ export const useToggleCompanyCategoryPublished = () => {
}; };
}, },
); );
}, [queryClient]); },
[queryClient],
);
const mutationKey = useMemo( const mutationKey = useMemo(
() => [ () => [
API_ENDPOINTS.COMPANIES.CATEGORIES.TOGGLE_PUBLISHED("published"), API_ENDPOINTS.COMPANIES.CATEGORIES.TOGGLE_PUBLISHED("published"),
@@ -245,8 +287,7 @@ export const useToggleCompanyCategoryPublished = () => {
data?: { message?: string }; data?: { message?: string };
message?: string; message?: string;
}; };
const toastMessage = const toastMessage = responseBody.data?.message ?? responseBody.message;
responseBody.data?.message ?? responseBody.message;
if (toastMessage) { if (toastMessage) {
toast.success(toastMessage); toast.success(toastMessage);
} }
+1
View File
@@ -4,6 +4,7 @@ import { useMemo } from "react";
export const useDashboardSummaryQuery = (params?: { export const useDashboardSummaryQuery = (params?: {
closing_window?: number; closing_window?: number;
days?: number;
}) => { }) => {
const queryKey = useMemo( const queryKey = useMemo(
() => [API_ENDPOINTS.DASHBOARD.SUMMARY, params], () => [API_ENDPOINTS.DASHBOARD.SUMMARY, params],
@@ -70,6 +70,10 @@ export const useCreateNotificationQuery = (successCallback?: () => void) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.NOTIFICATIONS.MY_NOTIFICATIONS], queryKey: [API_ENDPOINTS.NOTIFICATIONS.MY_NOTIFICATIONS],
}); });
queryClient.refetchQueries({
queryKey: [API_ENDPOINTS.NOTIFICATIONS.MY_NOTIFICATIONS],
type: "active",
});
if (successCallback) { if (successCallback) {
successCallback(); successCallback();
} }
+11
View File
@@ -0,0 +1,11 @@
import { API_ENDPOINTS, portalsService } from "@/lib/api";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
export const useGetPortalsQuery = () => {
const queryKey = useMemo(() => [API_ENDPOINTS.PORTALS.GET], []);
return useQuery({
queryKey,
queryFn: () => portalsService.getPortals(),
});
};
+2 -2
View File
@@ -14,7 +14,7 @@ export const useProfileQuery = () => {
}); });
}; };
export const useUpdateProfileQuery = () => { export const useUpdateProfileQuery = () => {
const { setUser } = useUser(); const { syncCurrentUser } = useUser();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutationKey = useMemo( const mutationKey = useMemo(
() => [API_ENDPOINTS.PROFILE.UPDATE, "UPDATE_USER_PROFILE"], () => [API_ENDPOINTS.PROFILE.UPDATE, "UPDATE_USER_PROFILE"],
@@ -28,7 +28,7 @@ export const useUpdateProfileQuery = () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.PROFILE.READ, "CURRENT_USER_PROFILE"], queryKey: [API_ENDPOINTS.PROFILE.READ, "CURRENT_USER_PROFILE"],
}); });
setUser(response); syncCurrentUser(response);
}, },
}); });
}; };
+7 -6
View File
@@ -1,9 +1,10 @@
import { API_ENDPOINTS, tendersService } from "@/lib/api"; import { API_ENDPOINTS, tendersService } from "@/lib/api";
import { TTenderListParams } from "@/lib/api/types/Tenders";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react"; import { useMemo } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
export const useTendersQuery = (params?: Record<string, any>) => { export const useTendersQuery = (params?: TTenderListParams) => {
const queryKey = useMemo( const queryKey = useMemo(
() => [API_ENDPOINTS.TENDERS.READ_ALL, "read-tenders", params], () => [API_ENDPOINTS.TENDERS.READ_ALL, "read-tenders", params],
[params], [params],
@@ -15,7 +16,10 @@ export const useTendersQuery = (params?: Record<string, any>) => {
}); });
}; };
export const useTenderDetailQuery = (id: string, params?: Record<string, any>) => { export const useTenderDetailQuery = (
id: string,
params?: Record<string, any>,
) => {
const queryKey = useMemo( const queryKey = useMemo(
() => [API_ENDPOINTS.TENDERS.DETAILS(id), params], () => [API_ENDPOINTS.TENDERS.DETAILS(id), params],
[id, params], [id, params],
@@ -52,10 +56,7 @@ export const useGetTenderDocumentsQuery = (
id: string, id: string,
options?: { enabled?: boolean }, options?: { enabled?: boolean },
) => { ) => {
const queryKey = useMemo( const queryKey = useMemo(() => [API_ENDPOINTS.TENDERS.DOCUMENTS(id)], [id]);
() => [API_ENDPOINTS.TENDERS.DOCUMENTS(id)],
[id],
);
return useQuery({ return useQuery({
queryKey, queryKey,
queryFn: () => tendersService.getDocuments(id), queryFn: () => tendersService.getDocuments(id),
+6 -152
View File
@@ -1,19 +1,11 @@
import { useUser } from "@/contexts"; import { useUser } from "@/contexts";
import { API_ENDPOINTS, userService } from "@/lib/api"; import { API_ENDPOINTS, userService } from "@/lib/api";
import { import { useMutation } from "@tanstack/react-query";
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useMemo } from "react"; import { useMemo } from "react";
import { toast } from "react-toastify";
export const useLoginQuery = (callback?: () => void) => { export const useLoginQuery = (callback?: () => void) => {
const mutationKey = useMemo(() => [API_ENDPOINTS.USER.LOGIN], []); const mutationKey = useMemo(() => [API_ENDPOINTS.USER.LOGIN], []);
const { setAuthState } = useUser(); const { setAuthState } = useUser();
const router = useRouter();
return useMutation({ return useMutation({
mutationKey, mutationKey,
mutationFn: userService.login, mutationFn: userService.login,
@@ -28,153 +20,15 @@ export const useLoginQuery = (callback?: () => void) => {
}); });
}; };
export const useDeleteAdmin = (onSuccessCallback: () => void) => {
const queryClient = useQueryClient();
const mutationKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, "delete"], []);
return useMutation({
mutationKey,
mutationFn: userService.deleteAdmin,
onSuccess: (response) => {
toast.info(response.data.message);
onSuccessCallback();
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.USER.ADMINS],
});
},
});
};
export const useLogoutQuery = () => { export const useLogoutQuery = () => {
const queryKey = useMemo(() => [API_ENDPOINTS.USER.LOGOUT], []); const { logout } = useUser();
const mutationKey = useMemo(() => [API_ENDPOINTS.USER.LOGOUT], []);
return useQuery({
queryKey,
queryFn: userService.logout,
});
};
export const useCreateAdmin = () => {
const queryClient = useQueryClient();
const router = useRouter();
const mutationKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, "create"], []);
return useMutation({ return useMutation({
mutationKey, mutationKey,
mutationFn: userService.createAdmin, mutationFn: () => userService.logout(),
onSuccess: (response) => { onSuccess: () => {
toast.success(response); logout();
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.USER.ADMINS],
});
router.push("/admins");
},
});
};
export const useUpdateAdmin = (id: string) => {
const queryClient = useQueryClient();
const router = useRouter();
const mutationKey = useMemo(
() => [API_ENDPOINTS.USER.ADMINS, "update", id],
[id],
);
return useMutation({
mutationKey,
mutationFn: userService.updateAdmin,
onSuccess: (response) => {
toast.success(response);
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.USER.ADMINS],
});
router.push("/admins");
},
});
};
export const useAdminDetails = (id: string) => {
const queryKey = useMemo(
() => [API_ENDPOINTS.USER.ADMINS, "details", id],
[id],
);
return useQuery({
queryKey,
queryFn: () => userService.adminDetails(id),
});
};
export const useAdminListInfiniteQuery = (
params?: Record<string, any>,
options?: { enabled?: boolean },
) => {
const enabled = options?.enabled ?? true;
const queryKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, params], [params]);
return useInfiniteQuery({
queryKey,
queryFn: ({ pageParam = 0 }) =>
userService.adminsList({ ...params, offset: pageParam }),
initialPageParam: 0,
enabled,
getNextPageParam: (lastPage) => {
if (!lastPage.meta) {
return undefined;
}
const { offset, limit, total } = lastPage.meta;
const nextOffset = offset + limit;
return nextOffset < total ? nextOffset : undefined;
},
});
};
export const useGetUsersQuery = (params?: Record<string, any>) => {
const queryKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, params], [params]);
return useQuery({
queryKey,
queryFn: () => userService.adminsList(params),
});
};
export const useResetAdminPassword = ({
successCallback,
}: {
successCallback?: (password: string) => void;
} = {}) => {
const mutationKey = useMemo(
() => [API_ENDPOINTS.USER.ADMINS, "reset-password"],
[],
);
return useMutation({
mutationKey,
mutationFn: userService.resetAdminPassword,
onSuccess: (response) => {
toast.success(response.message);
successCallback?.(response.data.password);
},
});
};
export const useChangeAdminStatus = ({
id,
successCallback,
}: {
id: string;
successCallback: () => void;
}) => {
const queryClient = useQueryClient();
const mutationKey = useMemo(
() => [API_ENDPOINTS.USER.CHANGE_ADMIN_STATUS(id)],
[id],
);
return useMutation({
mutationKey,
mutationFn: userService.changeAdminStatus,
onSuccess: ({ message }: { message: string }) => {
successCallback();
toast.success(message);
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.USER.ADMINS],
});
}, },
}); });
}; };
+22 -5
View File
@@ -1,6 +1,5 @@
"use client"; "use client";
import { IMetaData } from "@/lib/api/types/Factory";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
@@ -12,7 +11,14 @@ const isInvalidCursorError = (error: unknown) => {
}; };
type UseHybridPaginationOptions = { type UseHybridPaginationOptions = {
meta?: IMetaData; meta?: {
limit: number;
offset: number;
page?: number;
pages?: number;
has_more?: boolean;
next_cursor?: string;
};
params: Record<string, unknown>; params: Record<string, unknown>;
setParams: React.Dispatch<React.SetStateAction<Record<string, any>>>; setParams: React.Dispatch<React.SetStateAction<Record<string, any>>>;
isError?: boolean; isError?: boolean;
@@ -74,7 +80,7 @@ export const useHybridPagination = ({
if (cursorStack.length > 0) { if (cursorStack.length > 0) {
const last = cursorStack[cursorStack.length - 1]; const last = cursorStack[cursorStack.length - 1];
return Math.max(0, last.fromPage - 1 + cursorStack.length); return Math.max(0, last.fromPage);
} }
const paramsCursor = const paramsCursor =
@@ -91,7 +97,14 @@ export const useHybridPagination = ({
if (meta?.page != null) return meta.page - 1; if (meta?.page != null) return meta.page - 1;
return 0; return 0;
}, [cursorStack, params.cursor, params.offset, meta?.offset, meta?.page, limit]); }, [
cursorStack,
params.cursor,
params.offset,
meta?.offset,
meta?.page,
limit,
]);
const hasPrevious = cursorStack.length > 0 || currentPageIndex > 0; const hasPrevious = cursorStack.length > 0 || currentPageIndex > 0;
@@ -129,7 +142,11 @@ export const useHybridPagination = ({
{ cursor: nextCursor, fromPage: fromPage1Based }, { cursor: nextCursor, fromPage: fromPage1Based },
]); ]);
setParams((current) => { setParams((current) => {
const next: Record<string, any> = { ...current, cursor: nextCursor, limit }; const next: Record<string, any> = {
...current,
cursor: nextCursor,
limit,
};
delete next.offset; delete next.offset;
return next; return next;
}); });
+4
View File
@@ -33,6 +33,7 @@ export const API_ENDPOINTS = {
DELETE: (id: string) => `companies/${id}`, DELETE: (id: string) => `companies/${id}`,
DETAILS: (id: string) => `companies/${id}`, DETAILS: (id: string) => `companies/${id}`,
DOCUMENTS: (id: string) => `companies/${id}/documents`, DOCUMENTS: (id: string) => `companies/${id}/documents`,
LINKS: (id: string) => `companies/${id}/links`,
CATEGORIES: { CATEGORIES: {
READ_ALL: "company-categories", READ_ALL: "company-categories",
CREATE: "company-categories", CREATE: "company-categories",
@@ -92,4 +93,7 @@ export const API_ENDPOINTS = {
CLOSING_SOON: "dashboard/closing-soon", CLOSING_SOON: "dashboard/closing-soon",
RECENT: "dashboard/recent", RECENT: "dashboard/recent",
}, },
PORTALS: {
GET: "ai-pipeline/scrape/portals",
},
} as const; } as const;
+18 -1
View File
@@ -6,6 +6,8 @@ import {
TCompaniesResponse, TCompaniesResponse,
TCompanyCategoryApiResponse, TCompanyCategoryApiResponse,
TCreateCompanyCategoryCredentials, TCreateCompanyCategoryCredentials,
IAddCompanyLinksResponse,
ICompanyLink,
UploadDocumentsData, UploadDocumentsData,
} from "../types"; } from "../types";
@@ -87,10 +89,25 @@ export const companiesService = {
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error("ERROR caught in Companies Services Upload Documents:", error); console.error(
"ERROR caught in Companies Services Upload Documents:",
error,
);
throw error; throw error;
} }
}, },
addLinks: async ({
id,
links,
}: {
id: string;
links: ICompanyLink[];
}): Promise<ApiResponse<IAddCompanyLinksResponse>> => {
const response = await api.post(API_ENDPOINTS.COMPANIES.LINKS(id), {
links,
});
return response.data;
},
deleteCompany: async (id: string) => { deleteCompany: async (id: string) => {
try { try {
return (await api.delete(API_ENDPOINTS.COMPANIES.DELETE(id))).data; return (await api.delete(API_ENDPOINTS.COMPANIES.DELETE(id))).data;
@@ -13,6 +13,7 @@ import {
export const dashboardService = { export const dashboardService = {
summary: async (params?: { summary: async (params?: {
closing_window?: number; closing_window?: number;
days?: number;
}): Promise<ApiResponse<TDashboardSummary>> => { }): Promise<ApiResponse<TDashboardSummary>> => {
try { try {
return (await api.get(API_ENDPOINTS.DASHBOARD.SUMMARY, { params })).data; return (await api.get(API_ENDPOINTS.DASHBOARD.SUMMARY, { params })).data;
+2
View File
@@ -2,6 +2,8 @@ export * from "./companies-service";
export * from "./dashboard-service"; export * from "./dashboard-service";
export * from "./file-service"; export * from "./file-service";
export * from "./inquiries-service"; export * from "./inquiries-service";
export * from "./portals-service";
export * from "./profile-service"; export * from "./profile-service";
export * from "./tenders-service"; export * from "./tenders-service";
export * from "./user-services"; export * from "./user-services";
+15
View File
@@ -0,0 +1,15 @@
import api from "../axios";
import { API_ENDPOINTS } from "../endpoints";
import { ApiResponse, PortalsListResponseSchema, TPortal } from "../types";
export const portalsService = {
getPortals: async (): Promise<ApiResponse<TPortal[]>> => {
try {
const response = await api.get(API_ENDPOINTS.PORTALS.GET);
return PortalsListResponseSchema.parse(response.data);
} catch (error) {
console.error("ERROR caught in portals service => getPortals", error);
throw error;
}
},
};
+13 -5
View File
@@ -5,14 +5,16 @@ import { ApiResponse } from "../types";
import { import {
TTenderDetails, TTenderDetails,
TTenderDocumentsResponse, TTenderDocumentsResponse,
TTenderListParams,
TTenderListMeta,
TTenderResponse, TTenderResponse,
TTenderTranslationResponse, TTenderTranslationResponse,
} from "../types/Tenders"; } from "../types/Tenders";
export const tendersService = { export const tendersService = {
tendersList: async ( tendersList: async (
params?: Record<string, any>, params?: TTenderListParams,
): Promise<ApiResponse<TTenderResponse>> => { ): Promise<ApiResponse<TTenderResponse, TTenderListMeta>> => {
try { try {
return ( return (
await api.get(API_ENDPOINTS.TENDERS.READ_ALL, { await api.get(API_ENDPOINTS.TENDERS.READ_ALL, {
@@ -30,7 +32,8 @@ export const tendersService = {
params?: Record<string, any>, params?: Record<string, any>,
): Promise<ApiResponse<TTenderDetails>> => { ): Promise<ApiResponse<TTenderDetails>> => {
try { try {
return (await api.get(API_ENDPOINTS.TENDERS.DETAILS(id), { params })).data; return (await api.get(API_ENDPOINTS.TENDERS.DETAILS(id), { params }))
.data;
} catch (error) { } catch (error) {
console.error( console.error(
"ERROR caught in Tender Service => tender details: ", "ERROR caught in Tender Service => tender details: ",
@@ -53,12 +56,17 @@ export const tendersService = {
}) })
).data; ).data;
} catch (error) { } catch (error) {
console.error("ERROR caught in Tender Service => translate tender: ", error); console.error(
"ERROR caught in Tender Service => translate tender: ",
error,
);
throw error; throw error;
} }
}, },
getDocuments: async (id: string): Promise<ApiResponse<TTenderDocumentsResponse[]>> => { getDocuments: async (
id: string,
): Promise<ApiResponse<TTenderDocumentsResponse[]>> => {
try { try {
return (await api.get(API_ENDPOINTS.TENDERS.DOCUMENTS(id))).data; return (await api.get(API_ENDPOINTS.TENDERS.DOCUMENTS(id))).data;
} catch (error) { } catch (error) {
+20 -6
View File
@@ -28,6 +28,11 @@ export interface ITags {
cpv_codes: string[]; cpv_codes: string[];
} }
export interface ICompanyLink {
url: string;
title?: string | null;
}
export interface ICreateCompanyCredentials { export interface ICreateCompanyCredentials {
name: string; name: string;
email: string; email: string;
@@ -35,11 +40,11 @@ export interface ICreateCompanyCredentials {
website: string; website: string;
type: string; type: string;
description: string; description: string;
founded_year: number; founded_year?: number | string;
employee_count: number; employee_count?: number | string;
annual_revenue: number; annual_revenue?: number | string;
currency: string; currency: string;
industry: string; industry?: string;
language: string; language: string;
tax_id: string; tax_id: string;
registration_number: string; registration_number: string;
@@ -48,6 +53,7 @@ export interface ICreateCompanyCredentials {
contact_person: IContactPerson; contact_person: IContactPerson;
tags: ITags; tags: ITags;
document_file_ids?: string[]; document_file_ids?: string[];
links?: ICompanyLink[];
} }
export const companySchema = z.object({ export const companySchema = z.object({
@@ -62,7 +68,7 @@ export const companySchema = z.object({
employee_count: z.number(), employee_count: z.number(),
annual_revenue: z.number(), annual_revenue: z.number(),
currency: z.string(), currency: z.string(),
industry: z.string(), industry: z.string().optional(),
language: z.string(), language: z.string(),
tax_id: z.string(), tax_id: z.string(),
registration_number: z.string(), registration_number: z.string(),
@@ -92,6 +98,14 @@ export const companySchema = z.object({
cpv_codes: z.array(z.string()).optional(), cpv_codes: z.array(z.string()).optional(),
}), }),
document_file_ids: z.array(z.string()).optional(), document_file_ids: z.array(z.string()).optional(),
links: z
.array(
z.object({
url: z.string().url(),
title: z.string().nullable().optional(),
}),
)
.optional(),
}); });
export const companiesResponseSchema = z.object({ export const companiesResponseSchema = z.object({
@@ -104,7 +118,7 @@ export const companiesResponseSchema = z.object({
export const companyDetailsApiResponseSchema = export const companyDetailsApiResponseSchema =
createApiResponseSchema(companySchema); createApiResponseSchema(companySchema);
export const CompaniesListResponseSchema = createApiResponseSchema( export const CompaniesListResponseSchema = createApiResponseSchema(
companiesResponseSchema companiesResponseSchema,
); );
export type TCompany = z.infer<typeof companySchema>; export type TCompany = z.infer<typeof companySchema>;
export type TCompaniesResponse = z.infer<typeof companiesResponseSchema>; export type TCompaniesResponse = z.infer<typeof companiesResponseSchema>;
+3 -1
View File
@@ -7,6 +7,8 @@ export type TDashboardSummary = {
closing_soon: number; closing_soon: number;
total_estimated_value: number; total_estimated_value: number;
value_currency: string; value_currency: string;
days: number;
scraped_ted: TDashboardTrendPoint[];
generated_at: number; generated_at: number;
}; };
@@ -22,7 +24,6 @@ export type TDashboardTrend = {
}; };
export type TDashboardStatisticsDaily = { export type TDashboardStatisticsDaily = {
scraped_ted: TDashboardTrendPoint[];
scraped_documents: TDashboardTrendPoint[]; scraped_documents: TDashboardTrendPoint[];
translated_notices: TDashboardTrendPoint[]; translated_notices: TDashboardTrendPoint[];
}; };
@@ -30,6 +31,7 @@ export type TDashboardStatisticsDaily = {
export type TDashboardStatisticsTotals = { export type TDashboardStatisticsTotals = {
scraped_documents: number; scraped_documents: number;
translated_tenders: number; translated_tenders: number;
scraped_ted_notices: number;
}; };
export type TDashboardStatistics = { export type TDashboardStatistics = {
+49 -13
View File
@@ -1,6 +1,16 @@
import { z } from "zod"; import { z } from "zod";
import { createApiResponseSchema } from "./Factory"; import { createApiResponseSchema } from "./Factory";
const nullableStringArraySchema = z
.array(z.string())
.nullish()
.transform((value) => value ?? []);
const nullableArraySchema = z
.array(z.any())
.nullish()
.transform((value) => value ?? []);
export interface IAddress { export interface IAddress {
street: string; street: string;
city: string; city: string;
@@ -28,6 +38,16 @@ export interface ITags {
cpv_codes: string[]; cpv_codes: string[];
} }
export interface ICompanyLink {
url: string;
title?: string | null;
}
export interface IAddCompanyLinksResponse {
links: ICompanyLink[];
added: ICompanyLink[];
}
export interface ICreateCompanyCredentials { export interface ICreateCompanyCredentials {
name: string; name: string;
email: string; email: string;
@@ -35,11 +55,11 @@ export interface ICreateCompanyCredentials {
website: string; website: string;
type: string; type: string;
description: string; description: string;
founded_year: number; founded_year?: number | string;
employee_count: number; employee_count?: number | string;
annual_revenue: number; annual_revenue?: number | string;
currency: string; currency: string;
industry: string; industry?: string;
language: string; language: string;
tax_id: string; tax_id: string;
registration_number: string; registration_number: string;
@@ -48,6 +68,7 @@ export interface ICreateCompanyCredentials {
contact_person?: IContactPerson; contact_person?: IContactPerson;
tags: ITags; tags: ITags;
document_file_ids?: string[]; document_file_ids?: string[];
links?: ICompanyLink[];
} }
export const companySchema = z.object({ export const companySchema = z.object({
@@ -57,16 +78,21 @@ export const companySchema = z.object({
phone: z.string().optional(), phone: z.string().optional(),
website: z.string(), website: z.string(),
type: z.string(), type: z.string(),
status: z.string().optional(),
description: z.string(), description: z.string(),
founded_year: z.number(), founded_year: z.number().nullish(),
employee_count: z.number(), employee_count: z.number().nullish(),
annual_revenue: z.number(), annual_revenue: z.number().nullish(),
currency: z.string(), currency: z.string(),
industry: z.string(), industry: z.string().optional(),
language: z.string(), language: z.string(),
tax_id: z.string(), tax_id: z.string(),
registration_number: z.string(), registration_number: z.string(),
timezone: z.string(), timezone: z.string(),
is_verified: z.boolean().optional(),
is_compliant: z.boolean().optional(),
created_at: z.number().optional(),
updated_at: z.number().optional(),
address: z.object({ address: z.object({
street: z.string(), street: z.string(),
city: z.string(), city: z.string(),
@@ -85,15 +111,25 @@ export const companySchema = z.object({
position: z.string(), position: z.string(),
is_primary: z.boolean(), is_primary: z.boolean(),
}) })
.nullable()
.optional(), .optional(),
tags: z.object({ tags: z.object({
keywords: z.array(z.string()).or(z.null()).optional(), keywords: nullableStringArraySchema,
categories: z.array(z.any()).or(z.null()).optional(), categories: nullableArraySchema,
specializations: z.array(z.string()).or(z.null()).optional(), specializations: nullableStringArraySchema,
certifications: z.array(z.string()).or(z.null()).optional(), certifications: nullableStringArraySchema,
cpv_codes: z.array(z.string()).or(z.null()).optional(), cpv_codes: nullableStringArraySchema,
}), }),
document_file_ids: z.array(z.string()).or(z.null()).optional(), document_file_ids: z.array(z.string()).or(z.null()).optional(),
links: z
.array(
z.object({
url: z.string().url(),
title: z.string().nullable().optional(),
}),
)
.or(z.null())
.optional(),
}); });
export const companiesResponseSchema = z.object({ export const companiesResponseSchema = z.object({
+11
View File
@@ -0,0 +1,11 @@
import { z } from "zod";
import { createApiResponseSchema } from "./Factory";
export const PortalSchema = z.string();
export const PortalsListResponseSchema = createApiResponseSchema(
z.array(PortalSchema),
);
export type TPortal = z.infer<typeof PortalSchema>;
export type TPortalsListResponse = z.infer<typeof PortalsListResponseSchema>;
+25 -1
View File
@@ -123,9 +123,33 @@ export const TendersResponse = z.object({
tenders: z.array(TenderSchema).or(z.null()), tenders: z.array(TenderSchema).or(z.null()),
}); });
export type TTenderDocumentsResponse = z.infer<typeof TenderDocumentsResponseSchema>; export type TTenderDocumentsResponse = z.infer<
typeof TenderDocumentsResponseSchema
>;
export type TTenderResponse = z.infer<typeof TendersResponse>; export type TTenderResponse = z.infer<typeof TendersResponse>;
export type TTenderDetails = z.infer<typeof TenderSchema>; export type TTenderDetails = z.infer<typeof TenderSchema>;
export interface TTenderListParams {
limit?: number;
offset?: number;
cursor?: string;
include_total?: boolean;
sort_by?: string;
sort_order?: "asc" | "desc";
q?: string;
status?: string[];
country?: string;
documents_scraped?: boolean;
[key: string]: unknown;
}
export interface TTenderListMeta {
limit: number;
offset: number;
has_more: boolean;
next_cursor?: string;
total?: number;
page?: number;
pages?: number;
}
export type TTenderTranslationResponse = z.infer< export type TTenderTranslationResponse = z.infer<
typeof TenderTranslationResponseSchema typeof TenderTranslationResponseSchema
>; >;
+1 -1
View File
@@ -41,7 +41,7 @@ export const CreateAdminSchema = z.object({
.regex(/\d/, "Password must contain at least one number") .regex(/\d/, "Password must contain at least one number")
.regex(/[\W_]/, "Password must contain at least one special character"), .regex(/[\W_]/, "Password must contain at least one special character"),
profile_image: z.string().optional(), profile_image: z.string().nullable().optional(),
department: z.string().min(1, "Department is required"), department: z.string().min(1, "Department is required"),
position: z.string().min(1, "Position is required"), position: z.string().min(1, "Position is required"),
+6 -5
View File
@@ -1,14 +1,15 @@
export * from "./CompanyCategories"; export * from "./CompanyCategories";
export * from "./CompanyDocuments"; export * from "./CompanyDocuments";
export * from "./Customers"; export * from "./Customers";
export * from "./Dashboard";
export * from "./Factory"; export * from "./Factory";
export * from "./Feedback";
export * from "./Posts"; export * from "./Posts";
export * from "./Profile"; export * from "./Profile";
export * from "./shared"; export * from "./shared";
export * from "./TCompany";
export * from "./User";
export * from "./TInquiries";
export * from "./TCms"; export * from "./TCms";
export * from "./Feedback"; export * from "./TCompany";
export * from "./Tenders"; export * from "./Tenders";
export * from "./Dashboard"; export * from "./TInquiries";
export * from "./TPortals";
export * from "./User";
+2 -2
View File
@@ -1,9 +1,9 @@
import { IMetaData } from "./Factory"; import { IMetaData } from "./Factory";
export interface ApiResponse<T> { export interface ApiResponse<T, TMeta = IMetaData> {
data: T; data: T;
message: string; message: string;
code?: number; code?: number;
meta?: IMetaData; meta?: TMeta;
success: boolean; success: boolean;
} }