Compare commits
30 Commits
a62963a93d
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 5155c42146 | |||
| b87cf35f08 | |||
| 209acea88a | |||
| e3f1a0d1d2 | |||
| 7dc338dfcd | |||
| a762a14026 | |||
| 624614f1a9 | |||
| 37aa916ad5 | |||
| 4051cb156c | |||
| 5b43741d74 | |||
| 08938ad7f0 | |||
| 98fac2c867 | |||
| 3933efeddb | |||
| fc0d45e909 | |||
| 6b6dd7d2d1 | |||
| 0a28a1226f | |||
| 84482f3952 | |||
| 15315ebb47 | |||
| 5b5150e7da | |||
| 561931876f | |||
| a63ba453d7 | |||
| 9202f9a271 | |||
| 31d80cd6ae | |||
| 90a42b9fc5 | |||
| 5a6a5346c6 | |||
| d9cd09eeb8 | |||
| 169708e49d | |||
| 2fec8bb84c | |||
| 0af234b70b | |||
| 82b6e1b3d3 |
+42
-38
@@ -1,60 +1,64 @@
|
||||
################################################################################
|
||||
# web
|
||||
################################################################################
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: (admin Panel) build form and push to artifactory
|
||||
name: build-and-push-panel
|
||||
|
||||
steps:
|
||||
- name: build node app
|
||||
image: docker-mirror.ravanertebat.ir/base/node/node-cypress4:22.14
|
||||
|
||||
environment:
|
||||
PNPM_CONFIG_REGISTRY: https://mirror.ravanertebat.ir/repository/npm-proxy/
|
||||
#NPM_CONFIG_STRICT_SSL: false
|
||||
#NPM_CONFIG_AUDIT: false
|
||||
image: cypress/included:14.5.4
|
||||
commands:
|
||||
- |
|
||||
set -e
|
||||
case "$DRONE_BRANCH" in
|
||||
pro) echo prod;;
|
||||
uat) echo uat;;
|
||||
test) echo test;;
|
||||
*) echo dev;;
|
||||
esac > .environment
|
||||
- echo "Branch ${DRONE_BRANCH}, Environment $(cat .environment)"
|
||||
- APP_VERSION=$(cat .environment)-${DRONE_BUILD_NUMBER}
|
||||
- echo "APP Version tags $APP_VERSION"
|
||||
|
||||
SHORT_SHA=$(echo "$DRONE_COMMIT_SHA" | cut -c1-8)
|
||||
IMG_TAG="${DRONE_BUILD_NUMBER}-${DRONE_BRANCH}"
|
||||
echo -n "$IMG_TAG,$SHORT_SHA" > .tags
|
||||
echo "Image tags -> $IMG_TAG , $SHORT_SHA"
|
||||
- corepack enable
|
||||
- corepack prepare pnpm@10.18.0 --activate
|
||||
- mkdir -p ./artifacts/node
|
||||
- rm -rf .git
|
||||
# - corepack enable
|
||||
# - corepack prepare pnpm@10.18.0 --activate
|
||||
# - pnpm config list
|
||||
- rm -rf dist
|
||||
- rm -rf .git dist
|
||||
- pnpm install --frozen-lockfile --reporter=append-only
|
||||
- pnpm run test:e2e
|
||||
- pnpm run build
|
||||
- cp -R .next/standalone/. ./artifacts/node/
|
||||
- ls -ltrha ./artifacts/node
|
||||
|
||||
- name: build panel
|
||||
- name: push panel
|
||||
image: plugins/docker
|
||||
settings:
|
||||
# build_args:
|
||||
# - BUILD_APP=scraper
|
||||
username: cicd
|
||||
registry: git.opplens.se
|
||||
repo: git.opplens.se/mazyar/tm-panel
|
||||
username:
|
||||
from_secret: registry_username
|
||||
password:
|
||||
from_secret: REGISTRY_PASSWORD
|
||||
# dockerfile: Dockerfile-drone
|
||||
repo: artifactory.ravanertebat.ir/tm/admin_panel
|
||||
registry: artifactory.ravanertebat.ir
|
||||
from_secret: registry_password
|
||||
|
||||
- name: deploy-uat
|
||||
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:
|
||||
branch:
|
||||
- main
|
||||
- develop
|
||||
- uat
|
||||
- dev
|
||||
event:
|
||||
- custom
|
||||
- push
|
||||
|
||||
+1
-1
@@ -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_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0
|
||||
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com
|
||||
|
||||
@@ -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".
|
||||
@@ -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).
|
||||
@@ -46,6 +46,7 @@
|
||||
"react-toastify": "^11.0.5",
|
||||
"react-tooltip": "^5.29.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"uuid": "^14.0.1",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+9
@@ -104,6 +104,9 @@ importers:
|
||||
tailwind-merge:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.1
|
||||
uuid:
|
||||
specifier: ^14.0.1
|
||||
version: 14.0.1
|
||||
zod:
|
||||
specifier: ^4.1.5
|
||||
version: 4.3.6
|
||||
@@ -2977,6 +2980,10 @@ packages:
|
||||
util-deprecate@1.0.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
hasBin: true
|
||||
@@ -6218,6 +6225,8 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
uuid@14.0.1: {}
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
verror@1.10.0:
|
||||
|
||||
@@ -5,6 +5,7 @@ import DocumentItem from "@/components/forms/companies/documents/DocumentItem";
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
import Status from "@/components/ui/Status";
|
||||
import { useCompanyDetails } from "@/hooks/queries";
|
||||
import type { ICompanyLink } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BreadcrumbItem } from "@/types/shared";
|
||||
import { formatPhoneNumber, unixToDate } from "@/utils/shared";
|
||||
@@ -76,6 +77,9 @@ const CompanyDetailsPage = ({ params }: IProps) => {
|
||||
const documentFileIds: string[] = Array.isArray(company.document_file_ids)
|
||||
? company.document_file_ids
|
||||
: [];
|
||||
const companyLinks: ICompanyLink[] = Array.isArray(company.links)
|
||||
? company.links
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -145,12 +149,18 @@ const CompanyDetailsPage = ({ params }: IProps) => {
|
||||
{`${company.currency || "—"} ${getSymbolFromCurrency(company.currency ?? "USD")}`}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
<ShowcaseSection title="Annual revenue" {...SHOWCASE_CENTERED_BODY}>
|
||||
<ShowcaseSection
|
||||
title="Annual revenue"
|
||||
{...SHOWCASE_CENTERED_BODY}
|
||||
>
|
||||
<span className="font-medium text-dark dark:text-white">
|
||||
{company.annual_revenue || "—"}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
<ShowcaseSection title="Employee count" {...SHOWCASE_CENTERED_BODY}>
|
||||
<ShowcaseSection
|
||||
title="Employee count"
|
||||
{...SHOWCASE_CENTERED_BODY}
|
||||
>
|
||||
<span className="font-medium text-dark dark:text-white">
|
||||
{company.employee_count || "—"}
|
||||
</span>
|
||||
@@ -230,6 +240,35 @@ const CompanyDetailsPage = ({ params }: IProps) => {
|
||||
</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>
|
||||
</ShowcaseSection>
|
||||
</>
|
||||
|
||||
@@ -28,34 +28,41 @@ export function ClosingSoonCard({ tenders, 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(rootRef.current, {
|
||||
gsap.from(root, {
|
||||
opacity: 0,
|
||||
y: 24,
|
||||
duration: 0.6,
|
||||
ease: "power3.out",
|
||||
delay: 0.3,
|
||||
});
|
||||
gsap.from("[data-closing-row]", {
|
||||
x: 24,
|
||||
opacity: 0,
|
||||
duration: 0.45,
|
||||
stagger: 0.07,
|
||||
delay: 0.55,
|
||||
ease: "power2.out",
|
||||
});
|
||||
}, rootRef);
|
||||
|
||||
const rows = root.querySelectorAll("[data-closing-row]");
|
||||
if (rows.length > 0) {
|
||||
gsap.from(rows, {
|
||||
x: 24,
|
||||
opacity: 0,
|
||||
duration: 0.45,
|
||||
stagger: 0.07,
|
||||
delay: 0.55,
|
||||
ease: "power2.out",
|
||||
});
|
||||
}
|
||||
}, root);
|
||||
return () => ctx.revert();
|
||||
}, [tenders.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: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 gap-2">
|
||||
@@ -66,7 +73,9 @@ export function ClosingSoonCard({ tenders, isLoading }: Props) {
|
||||
<h2 className="text-base font-bold text-dark dark:text-white">
|
||||
Closing Soon
|
||||
</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>
|
||||
<Link
|
||||
@@ -80,7 +89,10 @@ export function ClosingSoonCard({ tenders, isLoading }: Props) {
|
||||
<ul className="flex-1 divide-y divide-stroke 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">
|
||||
<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="flex-1 space-y-2">
|
||||
<div className="h-3 w-3/4 rounded bg-gray-2 dark:bg-dark-3" />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ClosingSoonCard } from "./closing-soon";
|
||||
import { CountryDistribution } from "./country-distribution";
|
||||
import { DashboardHero } from "./hero";
|
||||
import { NoticeTypeBreakdown } from "./notice-types";
|
||||
import { ScrapePortals } from "./portals";
|
||||
import { RecentTenders } from "./recent-tenders";
|
||||
import { StatisticsSection } from "./statistics-section";
|
||||
import { TenderStatCards } from "./stat-cards";
|
||||
@@ -19,11 +20,11 @@ export function TenderDashboard() {
|
||||
totalValue,
|
||||
valueCurrency,
|
||||
countries,
|
||||
noticeTypes,
|
||||
trend,
|
||||
recent,
|
||||
recentIsLoading,
|
||||
closingSoonList,
|
||||
portals,
|
||||
portalsIsLoading,
|
||||
} = useDashboardData();
|
||||
|
||||
return (
|
||||
@@ -53,19 +54,22 @@ export function TenderDashboard() {
|
||||
<CountryDistribution countries={countries} isLoading={isPending} />
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 xl:col-span-5">
|
||||
{/* <div className="col-span-12 xl:col-span-5">
|
||||
<NoticeTypeBreakdown
|
||||
noticeTypes={noticeTypes}
|
||||
isLoading={isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-12 xl:col-span-7">
|
||||
</div> */}
|
||||
{/* <div className="col-span-12 xl:col-span-7">
|
||||
<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} />
|
||||
</div>
|
||||
<div className="col-span-12 xl:col-span-4">
|
||||
<ScrapePortals portals={portals} isLoading={portalsIsLoading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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,34 +45,41 @@ export function RecentTenders({ tenders, 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(rootRef.current, {
|
||||
gsap.from(root, {
|
||||
opacity: 0,
|
||||
y: 24,
|
||||
duration: 0.6,
|
||||
ease: "power3.out",
|
||||
delay: 0.35,
|
||||
});
|
||||
gsap.from("[data-recent-row]", {
|
||||
x: -24,
|
||||
opacity: 0,
|
||||
duration: 0.5,
|
||||
stagger: 0.06,
|
||||
delay: 0.55,
|
||||
ease: "power2.out",
|
||||
});
|
||||
}, rootRef);
|
||||
|
||||
const rows = root.querySelectorAll("[data-recent-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();
|
||||
}, [tenders.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: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 gap-2">
|
||||
|
||||
@@ -8,17 +8,22 @@ import {
|
||||
useDashboardTrendQuery,
|
||||
useTendersQuery,
|
||||
} from "@/hooks/queries";
|
||||
import { useGetPortalsQuery } from "@/hooks/queries/usePortalQueries";
|
||||
import { useMemo } from "react";
|
||||
import { toLabeledPoints } from "./to-labeled-points";
|
||||
|
||||
export type DashboardMetrics = ReturnType<typeof useDashboardData>;
|
||||
|
||||
export function useDashboardData() {
|
||||
const summaryQuery = useDashboardSummaryQuery();
|
||||
const summaryQuery = useDashboardSummaryQuery({
|
||||
closing_window: 168,
|
||||
days: 14,
|
||||
});
|
||||
const trendQuery = useDashboardTrendQuery({ days: 14, metric: "created" });
|
||||
const countriesQuery = useDashboardCountriesQuery({ limit: 6 });
|
||||
const noticeTypesQuery = useDashboardNoticeTypesQuery();
|
||||
const closingSoonQuery = useDashboardClosingSoonQuery({ limit: 5 });
|
||||
const portalsQuery = useGetPortalsQuery();
|
||||
|
||||
const {
|
||||
data: recentData,
|
||||
@@ -87,6 +92,8 @@ export function useDashboardData() {
|
||||
|
||||
const recent = recentData?.data?.tenders ?? [];
|
||||
const recentIsLoading = recentPending && recent.length === 0;
|
||||
const portals = portalsQuery.data?.data ?? [];
|
||||
const portalsIsLoading = portalsQuery.isPending && portals.length === 0;
|
||||
|
||||
return {
|
||||
isPending,
|
||||
@@ -104,5 +111,7 @@ export function useDashboardData() {
|
||||
recent,
|
||||
recentIsLoading,
|
||||
closingSoonList,
|
||||
portals,
|
||||
portalsIsLoading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useDashboardStatisticsQuery } from "@/hooks/queries";
|
||||
import {
|
||||
useDashboardStatisticsQuery,
|
||||
useDashboardSummaryQuery,
|
||||
} from "@/hooks/queries";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toLabeledPoints } from "./to-labeled-points";
|
||||
|
||||
@@ -11,14 +14,16 @@ export type StatisticsDays = (typeof STATISTICS_DAY_OPTIONS)[number];
|
||||
export function useStatistics() {
|
||||
const [days, setDays] = useState<StatisticsDays>(14);
|
||||
|
||||
const summaryQuery = useDashboardSummaryQuery({ closing_window: 168, days });
|
||||
const query = useDashboardStatisticsQuery({ days });
|
||||
const summary = summaryQuery.data?.data;
|
||||
const stats = query.data?.data;
|
||||
|
||||
const daily = stats?.daily;
|
||||
|
||||
const scrapedTed = useMemo(
|
||||
() => toLabeledPoints(daily?.scraped_ted ?? []),
|
||||
[daily],
|
||||
() => toLabeledPoints(summary?.scraped_ted ?? []),
|
||||
[summary],
|
||||
);
|
||||
const scrapedDocuments = useMemo(
|
||||
() => toLabeledPoints(daily?.scraped_documents ?? []),
|
||||
@@ -32,9 +37,12 @@ export function useStatistics() {
|
||||
return {
|
||||
days,
|
||||
setDays,
|
||||
isLoading: query.isPending,
|
||||
isError: query.isError,
|
||||
refetch: query.refetch,
|
||||
isLoading: query.isPending || summaryQuery.isPending,
|
||||
isError: query.isError || summaryQuery.isError,
|
||||
refetch: () => {
|
||||
summaryQuery.refetch();
|
||||
query.refetch();
|
||||
},
|
||||
scrapedTed,
|
||||
scrapedDocuments,
|
||||
translatedNotices,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
|
||||
import { createPhoneFieldRules } from "@/components/FormElements/PhoneNumberInput";
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
import { FormErrorMessages } from "@/constants/Texts";
|
||||
import { forwardRef } from "react";
|
||||
@@ -46,11 +47,16 @@ const FooterStep = forwardRef<unknown, FooterStepProps>(
|
||||
</p>
|
||||
)}
|
||||
<InputGroup
|
||||
{...register("phone")}
|
||||
{...register("phone", createPhoneFieldRules())}
|
||||
label="Phone Number"
|
||||
name="phone"
|
||||
type="text"
|
||||
/>
|
||||
{errors.phone && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.phone.message as string}
|
||||
</p>
|
||||
)}
|
||||
<InputGroup
|
||||
{...register("location")}
|
||||
label="Location"
|
||||
|
||||
@@ -103,6 +103,10 @@ const HeroStep = forwardRef<unknown, HeroStepProps>(({ initialData, onValidityCh
|
||||
value: 200,
|
||||
message: FormErrorMessages.maxLength(200),
|
||||
},
|
||||
pattern: {
|
||||
value: REGEX.URL,
|
||||
message: FormErrorMessages.invalidPattern,
|
||||
},
|
||||
})}
|
||||
label="Button Link"
|
||||
name="button_link"
|
||||
|
||||
@@ -20,6 +20,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
<SidebarProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastContainer
|
||||
className="!z-[1000001]"
|
||||
position="top-right"
|
||||
autoClose={2500}
|
||||
theme={"colored"}
|
||||
|
||||
@@ -29,9 +29,7 @@ interface IProps {
|
||||
// Backend has shipped the documents payload as a bare array, an `ApiResponse`
|
||||
// wrapper, and a `{ data: { documents: [...] } }` wrapper at different times.
|
||||
// Normalize so a real list is never dropped because of a wrapper mismatch.
|
||||
function extractTenderDocuments(
|
||||
payload: unknown,
|
||||
): TTenderDocumentsResponse[] {
|
||||
function extractTenderDocuments(payload: unknown): TTenderDocumentsResponse[] {
|
||||
if (!payload) return [];
|
||||
if (Array.isArray(payload)) return payload as TTenderDocumentsResponse[];
|
||||
|
||||
@@ -58,12 +56,12 @@ const TenderDetails = ({ params }: IProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const selectedLanguage = searchParams.get("lang") || "en";
|
||||
const selectedLanguage = searchParams.get("lang") ?? "";
|
||||
const { mutate: translateTender, isPending: isTranslating } =
|
||||
useTranslateTenderMutation();
|
||||
const { data, isLoading, isError, isFetching } = useTenderDetailQuery(
|
||||
details,
|
||||
{ language: selectedLanguage },
|
||||
selectedLanguage ? { language: selectedLanguage } : undefined,
|
||||
);
|
||||
|
||||
const breadcrumbItems = [
|
||||
@@ -107,7 +105,7 @@ const TenderDetails = ({ params }: IProps) => {
|
||||
const nextLanguage = event.target.value;
|
||||
if (!nextLanguage) return;
|
||||
|
||||
const prevLanguage = searchParams.get("lang") || "en";
|
||||
const prevLanguage = searchParams.get("lang") ?? "";
|
||||
if (nextLanguage === prevLanguage) return;
|
||||
|
||||
userPendingLangRef.current = nextLanguage;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type UseFormRegister,
|
||||
type UseFormRegisterReturn,
|
||||
} from "react-hook-form";
|
||||
import { get } from "react-hook-form";
|
||||
|
||||
type InputGroupProps<T extends FieldValues> = {
|
||||
name: Path<T>;
|
||||
@@ -58,7 +59,7 @@ const InputGroup = <T extends FieldValues>({
|
||||
}: InputGroupProps<T>): React.ReactElement => {
|
||||
const id = useId();
|
||||
|
||||
const error = errors && errors[name];
|
||||
const error = errors && get(errors, name);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
|
||||
@@ -153,38 +153,43 @@ type PropsType<T extends FieldValues> = {
|
||||
) &
|
||||
Partial<UseFormRegisterReturn>;
|
||||
|
||||
export function Select<T extends FieldValues>({
|
||||
items,
|
||||
label,
|
||||
defaultValue,
|
||||
placeholder,
|
||||
prefixIcon,
|
||||
className,
|
||||
name,
|
||||
errors,
|
||||
required,
|
||||
onChange,
|
||||
onBlur,
|
||||
ref,
|
||||
clearable = false,
|
||||
onClear,
|
||||
value: controlledValue,
|
||||
dataCy,
|
||||
disabled = false,
|
||||
searchable = false,
|
||||
searchPlaceholder = "Search by label or value…",
|
||||
searchMode = "client",
|
||||
searchDebounceMs = 300,
|
||||
searchQueryKey,
|
||||
searchFn,
|
||||
onSearch,
|
||||
onLoadMore,
|
||||
hasMore = false,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
register: _register,
|
||||
...props
|
||||
}: PropsType<T>) {
|
||||
export function Select<T extends FieldValues>(selectProps: PropsType<T>) {
|
||||
const hasControlledValue = Object.prototype.hasOwnProperty.call(
|
||||
selectProps,
|
||||
"value",
|
||||
);
|
||||
const {
|
||||
items,
|
||||
label,
|
||||
defaultValue,
|
||||
placeholder,
|
||||
prefixIcon,
|
||||
className,
|
||||
name,
|
||||
errors,
|
||||
required,
|
||||
onChange,
|
||||
onBlur,
|
||||
ref,
|
||||
clearable = false,
|
||||
onClear,
|
||||
value: controlledValue,
|
||||
dataCy,
|
||||
disabled = false,
|
||||
searchable = false,
|
||||
searchPlaceholder = "Search by label or value…",
|
||||
searchMode = "client",
|
||||
searchDebounceMs = 300,
|
||||
searchQueryKey,
|
||||
searchFn,
|
||||
onSearch,
|
||||
onLoadMore,
|
||||
hasMore = false,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
register: _register,
|
||||
...props
|
||||
} = selectProps;
|
||||
void _register;
|
||||
|
||||
/** 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];
|
||||
|
||||
useEffect(() => {
|
||||
if (controlledValue !== undefined) {
|
||||
setValue(controlledValue);
|
||||
setIsOptionSelected(!!controlledValue);
|
||||
if (hasControlledValue) {
|
||||
const nextValue = controlledValue ?? "";
|
||||
setValue(nextValue);
|
||||
setIsOptionSelected(!!nextValue);
|
||||
}
|
||||
}, [controlledValue]);
|
||||
}, [controlledValue, hasControlledValue]);
|
||||
|
||||
const {
|
||||
isServerMode,
|
||||
@@ -333,7 +339,7 @@ export function Select<T extends FieldValues>({
|
||||
}, [open, showSearch]);
|
||||
|
||||
const emitChange = (next: string) => {
|
||||
if (controlledValue === undefined) {
|
||||
if (!hasControlledValue) {
|
||||
setValue(next);
|
||||
}
|
||||
setIsOptionSelected(!!next);
|
||||
@@ -345,7 +351,7 @@ export function Select<T extends FieldValues>({
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
if (controlledValue === undefined) {
|
||||
if (!hasControlledValue) {
|
||||
setValue("");
|
||||
}
|
||||
setIsOptionSelected(false);
|
||||
@@ -498,7 +504,7 @@ export function Select<T extends FieldValues>({
|
||||
data-cy={dataCy}
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
if (controlledValue === undefined) {
|
||||
if (!hasControlledValue) {
|
||||
setValue(e.target.value);
|
||||
}
|
||||
setIsOptionSelected(!!e.target.value);
|
||||
@@ -612,7 +618,7 @@ export function Select<T extends FieldValues>({
|
||||
{dropdown}
|
||||
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -5,29 +5,54 @@ import {
|
||||
DropdownContent,
|
||||
DropdownTrigger,
|
||||
} from "@/components/ui/dropdown";
|
||||
import { useUser } from "@/contexts";
|
||||
import Modal from "@/components/ui/modal";
|
||||
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 { TNotificationDetailsResponse } from "@/lib/api/types/NotificationHistory";
|
||||
import { cn, sanitizeHtmlWithStyles } from "@/lib/utils";
|
||||
import { capitalize, unixToDate } from "@/utils/shared";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { BellIcon } from "./icons";
|
||||
import NotificationList from "./notification-list";
|
||||
|
||||
export function Notification() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDotVisible, setIsDotVisible] = useState(true);
|
||||
const [isBadgeVisible, setIsBadgeVisible] = useState(true);
|
||||
const [unreadNotificationCount, setUnreadNotificationCount] =
|
||||
useState<number>(0);
|
||||
const previousUnreadCountRef = useRef(0);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [currentNotification, setCurrentNotification] =
|
||||
useState<TNotificationDetailsResponse | null>(null);
|
||||
|
||||
const { mutate } = useMarkNotificationAsSeen();
|
||||
const { data: unreadNotifications, isLoading: isLoadingNotifications } =
|
||||
useGetMyNotifications({ seen: false });
|
||||
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 = (
|
||||
notification: TNotificationDetailsResponse,
|
||||
@@ -44,7 +69,7 @@ export function Notification() {
|
||||
isOpen={isOpen}
|
||||
setIsOpen={(open) => {
|
||||
setIsOpen(open);
|
||||
if (setIsDotVisible) setIsDotVisible(false);
|
||||
if (open) setIsBadgeVisible(false);
|
||||
}}
|
||||
>
|
||||
<DropdownTrigger
|
||||
@@ -53,7 +78,7 @@ export function Notification() {
|
||||
>
|
||||
<span className="relative">
|
||||
<BellIcon />
|
||||
{isDotVisible && unreadNotificationCount > 0 && (
|
||||
{isBadgeVisible && unreadNotificationCount > 0 && (
|
||||
<span
|
||||
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",
|
||||
@@ -81,12 +106,13 @@ export function Notification() {
|
||||
</div>
|
||||
|
||||
<NotificationList
|
||||
setUnreadNotificationCount={setUnreadNotificationCount}
|
||||
notifications={unreadNotifications?.data}
|
||||
isLoading={isLoadingNotifications}
|
||||
onNotificationClick={handleNotificationClick}
|
||||
/>
|
||||
|
||||
<Link
|
||||
href="/notification-history"
|
||||
href={notificationHistoryHref}
|
||||
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"
|
||||
>
|
||||
@@ -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="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 />
|
||||
</span>
|
||||
|
||||
@@ -176,7 +202,7 @@ export function Notification() {
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
|
||||
@@ -2,36 +2,29 @@
|
||||
|
||||
import Loading from "@/components/loading";
|
||||
import IsVisible from "@/components/ui/IsVisible";
|
||||
import { useGetMyNotifications } from "@/hooks/queries/useNotificationQueries";
|
||||
import { TNotificationDetailsResponse } from "@/lib/api/types/NotificationHistory";
|
||||
import { truncateString } from "@/utils/shared";
|
||||
import { Dispatch, SetStateAction, useEffect } from "react";
|
||||
|
||||
interface IProps {
|
||||
setUnreadNotificationCount: Dispatch<SetStateAction<number>>;
|
||||
notifications?: TNotificationDetailsResponse[];
|
||||
isLoading: boolean;
|
||||
onNotificationClick: (notification: TNotificationDetailsResponse) => void;
|
||||
}
|
||||
|
||||
const NotificationList = ({
|
||||
setUnreadNotificationCount,
|
||||
notifications,
|
||||
isLoading,
|
||||
onNotificationClick,
|
||||
}: IProps) => {
|
||||
const { data, isLoading } = useGetMyNotifications({ seen: false });
|
||||
useEffect(() => {
|
||||
setUnreadNotificationCount(data?.data.length ?? 0);
|
||||
}, [data, isLoading]);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<Loading className="min-h-48 flex-none justify-center p-6" />
|
||||
);
|
||||
return <Loading className="min-h-48 flex-none justify-center p-6" />;
|
||||
|
||||
return (
|
||||
<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>
|
||||
</IsVisible>
|
||||
{data?.data.map((notification) => (
|
||||
{notifications?.map((notification) => (
|
||||
<li
|
||||
role="menuitem"
|
||||
key={notification.id}
|
||||
|
||||
@@ -104,7 +104,7 @@ export const useCompanyListPresenter = () => {
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
currency: undefined,
|
||||
currency: "",
|
||||
employee_count: "",
|
||||
});
|
||||
router.push(pathName);
|
||||
|
||||
@@ -156,17 +156,27 @@ const useCustomerListPresenter = () => {
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
filterFormReset({
|
||||
status: undefined,
|
||||
role: undefined,
|
||||
type: undefined,
|
||||
full_name: undefined,
|
||||
email: undefined,
|
||||
company_id: undefined,
|
||||
});
|
||||
const clearedFilters = {
|
||||
...apiDefaultParams,
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
status: "",
|
||||
role: "",
|
||||
type: "",
|
||||
full_name: "",
|
||||
email: "",
|
||||
company_id: "",
|
||||
};
|
||||
|
||||
filterFormReset(clearedFilters);
|
||||
setFilterValue("status", "");
|
||||
setFilterValue("role", "");
|
||||
setFilterValue("type", "");
|
||||
setFilterValue("company_id", "");
|
||||
setCompanySearch("");
|
||||
setParams(clearedFilters);
|
||||
router.push(pathName);
|
||||
}, [pathName, router, filterFormReset]);
|
||||
}, [pathName, router, filterFormReset, setFilterValue]);
|
||||
|
||||
const columns = [
|
||||
"row",
|
||||
|
||||
@@ -11,14 +11,12 @@ interface ChangeStatusFormProps {
|
||||
onSubmit: (data: TChangeInquiryStatusCredentials) => void;
|
||||
onCancel: () => void;
|
||||
currentStatus: string;
|
||||
|
||||
}
|
||||
|
||||
const ChangeStatusForm = ({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
currentStatus,
|
||||
|
||||
}: ChangeStatusFormProps) => {
|
||||
const {
|
||||
register,
|
||||
@@ -37,10 +35,19 @@ const ChangeStatusForm = ({
|
||||
value: status,
|
||||
}));
|
||||
|
||||
const handleStatusSubmit = (data: TChangeInquiryStatusCredentials) => {
|
||||
if (data.status === currentStatus) return;
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<form onSubmit={handleSubmit(handleStatusSubmit)} className="space-y-4">
|
||||
<Select
|
||||
{...register("status", { required: "Status is required" })}
|
||||
{...register("status", {
|
||||
required: "Status is required",
|
||||
validate: (value) =>
|
||||
value !== currentStatus || "Select a different status",
|
||||
})}
|
||||
name="status"
|
||||
label="Status"
|
||||
items={statusOptions}
|
||||
|
||||
@@ -4,6 +4,10 @@ import InputGroup from "@/components/FormElements/InputGroup";
|
||||
import { Select } from "@/components/FormElements/select";
|
||||
import { Button } from "@/components/ui-elements/button";
|
||||
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 {
|
||||
FieldValues,
|
||||
UseFormHandleSubmit,
|
||||
@@ -34,11 +38,38 @@ function countActive(values: Record<string, unknown>): number {
|
||||
) {
|
||||
n++;
|
||||
}
|
||||
const userId = values.user_id;
|
||||
if (
|
||||
userId !== undefined &&
|
||||
userId !== null &&
|
||||
userId !== "" &&
|
||||
String(userId).trim()
|
||||
) {
|
||||
n++;
|
||||
}
|
||||
const searchVal = values.search;
|
||||
if (typeof searchVal === "string" && searchVal.trim()) 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 = ({
|
||||
filterRegister,
|
||||
isMutating,
|
||||
@@ -48,7 +79,22 @@ const NotificationHistoryListFilters = ({
|
||||
setValue,
|
||||
onClearAll,
|
||||
}: NotificationHistoryListFiltersProps) => {
|
||||
const { user } = useUser();
|
||||
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(
|
||||
() => countActive(watched as Record<string, unknown>),
|
||||
[watched],
|
||||
@@ -68,7 +114,7 @@ const NotificationHistoryListFilters = ({
|
||||
onSubmit={handleFilterSubmit(search)}
|
||||
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
|
||||
{...filterRegister("seen")}
|
||||
items={[
|
||||
@@ -82,6 +128,22 @@ const NotificationHistoryListFilters = ({
|
||||
value={watch("seen")}
|
||||
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
|
||||
{...filterRegister("search")}
|
||||
name="search"
|
||||
|
||||
@@ -83,7 +83,7 @@ const useNotificationHistoryTablePresenter = () => {
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
filterFormReset({ seen: undefined, search: "" });
|
||||
filterFormReset({ seen: undefined, search: "", user_id: undefined });
|
||||
router.push(pathName);
|
||||
}, [pathName, router, filterFormReset]);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { isDateRangeActive } from "@/utils/shared";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Control,
|
||||
Controller,
|
||||
FieldValues,
|
||||
UseFormHandleSubmit,
|
||||
UseFormRegister,
|
||||
@@ -214,12 +215,22 @@ const TenderListFilters = ({
|
||||
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">
|
||||
<Checkbox
|
||||
label="Documents scraped"
|
||||
minimal
|
||||
withBg
|
||||
withIcon="check"
|
||||
{...filterRegister("documents_scraped")}
|
||||
<Controller
|
||||
name="documents_scraped"
|
||||
control={control}
|
||||
render={({ field: { value, onChange, name, onBlur, ref } }) => (
|
||||
<Checkbox
|
||||
ref={ref}
|
||||
name={name}
|
||||
label="Documents scraped"
|
||||
minimal
|
||||
withBg
|
||||
withIcon="check"
|
||||
checked={value === true}
|
||||
onBlur={onBlur}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleFilterSection>
|
||||
|
||||
@@ -5,7 +5,6 @@ import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||
import IsVisible from "@/components/ui/IsVisible";
|
||||
import ListHeader from "@/components/ui/ListHeader";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import Pagination from "@/components/ui/pagination";
|
||||
import Status from "@/components/ui/Status";
|
||||
import {
|
||||
Table,
|
||||
@@ -49,7 +48,8 @@ const TendersTable = () => {
|
||||
navigateToTenderDetails,
|
||||
navigateToTenderFeedback,
|
||||
handlePaginationChange,
|
||||
buildWindowedPageLabel,
|
||||
hasNext,
|
||||
hasPrevious,
|
||||
getRowNumber,
|
||||
formatDateTimeCell,
|
||||
pagination,
|
||||
@@ -204,16 +204,37 @@ const TendersTable = () => {
|
||||
/>
|
||||
</div>
|
||||
<IsVisible condition={shouldShowPagination}>
|
||||
<Pagination
|
||||
key={`pagination-tenders-${absoluteCurrentPage}`}
|
||||
currentPage={pagination.currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={handlePaginationChange}
|
||||
pageRangeDisplayed={3}
|
||||
marginPagesDisplayed={1}
|
||||
pageLabelBuilder={buildWindowedPageLabel}
|
||||
variant="tenders"
|
||||
/>
|
||||
<nav
|
||||
className="mt-2 flex w-full items-center justify-center gap-3 text-sm"
|
||||
aria-label="Tender pagination"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasPrevious}
|
||||
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"
|
||||
>
|
||||
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>
|
||||
</ListWrapper>
|
||||
);
|
||||
|
||||
@@ -8,14 +8,20 @@ import { TCustomer } from "@/lib/api";
|
||||
import {
|
||||
buildQueryString,
|
||||
deleteEmptyKeys,
|
||||
getPaginatedRowNumber,
|
||||
isDateRangeActive,
|
||||
unixToDate,
|
||||
} from "@/utils/shared";
|
||||
import gsap from "gsap";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
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";
|
||||
|
||||
const COMMA_SEPARATED_FILTER_KEYS = new Set([
|
||||
@@ -173,6 +179,7 @@ const mergeTenderFilterFormState = (params: Record<string, any>) => {
|
||||
return {
|
||||
...TENDER_FILTER_FORM_DEFAULTS,
|
||||
...built,
|
||||
documents_scraped: built.documents_scraped === true,
|
||||
created_at_range: built.created_at_range ?? [],
|
||||
tender_deadline_range: built.tender_deadline_range ?? [],
|
||||
publication_date_range: built.publication_date_range ?? [],
|
||||
@@ -356,11 +363,10 @@ const useTenderListPresenter = () => {
|
||||
useEffect(() => {
|
||||
const newParams: Record<string, any> = {
|
||||
...apiDefaultParams,
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
limit: 20,
|
||||
};
|
||||
for (const key of searchParams.keys()) {
|
||||
if (key === "cpv_codes") {
|
||||
if (key === "cpv_codes" || key === "include_total" || key === "cursor") {
|
||||
continue;
|
||||
}
|
||||
newParams[key] = searchParams.get(key);
|
||||
@@ -376,53 +382,37 @@ const useTenderListPresenter = () => {
|
||||
|
||||
const { data, error, isPending, isError } = useTendersQuery(params);
|
||||
|
||||
const { pagination, handlePaginationChange, buildFirstPageParams } =
|
||||
useHybridPagination({
|
||||
meta: data?.meta,
|
||||
params,
|
||||
setParams,
|
||||
isError,
|
||||
error,
|
||||
});
|
||||
const {
|
||||
pagination,
|
||||
handlePaginationChange: changePaginationPage,
|
||||
buildFirstPageParams,
|
||||
hasNext,
|
||||
hasPrevious,
|
||||
} = useHybridPagination({
|
||||
meta: data?.meta,
|
||||
params,
|
||||
setParams,
|
||||
isError,
|
||||
error,
|
||||
});
|
||||
|
||||
const MAX_PAGE_JUMP = 10;
|
||||
const paginationWindow = useMemo(() => {
|
||||
const windowStart = Math.max(0, pagination.currentPage - MAX_PAGE_JUMP);
|
||||
const windowEnd = Math.min(
|
||||
Math.max(pagination.totalPages - 1, 0),
|
||||
pagination.currentPage + MAX_PAGE_JUMP,
|
||||
);
|
||||
return {
|
||||
windowStart,
|
||||
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,
|
||||
});
|
||||
const handlePaginationChange = useCallback(
|
||||
(event: { selected: number }) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const reduceMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: reduceMotion ? "auto" : "smooth",
|
||||
});
|
||||
}
|
||||
changePaginationPage(event);
|
||||
},
|
||||
[handlePaginationChange, paginationWindow.windowStart],
|
||||
[changePaginationPage],
|
||||
);
|
||||
|
||||
const buildWindowedPageLabel = useCallback(
|
||||
(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 shouldShowPagination = !isPending && (hasPrevious || hasNext);
|
||||
|
||||
const tendersList = data?.data?.tenders ?? [];
|
||||
|
||||
@@ -449,12 +439,11 @@ const useTenderListPresenter = () => {
|
||||
|
||||
const getRowNumber = useCallback(
|
||||
(index: number) =>
|
||||
getPaginatedRowNumber({
|
||||
index,
|
||||
page: data?.meta?.page,
|
||||
limit: data?.meta?.limit,
|
||||
}),
|
||||
[data?.meta?.limit, data?.meta?.page],
|
||||
pagination.currentPage *
|
||||
(data?.meta?.limit ?? Number(params.limit) ?? 20) +
|
||||
index +
|
||||
1,
|
||||
[data?.meta?.limit, pagination.currentPage, params.limit],
|
||||
);
|
||||
|
||||
const formatDateTimeCell = useCallback((unix: number) => {
|
||||
@@ -541,7 +530,7 @@ const useTenderListPresenter = () => {
|
||||
const clearAllFilters = useCallback(() => {
|
||||
const cleared = buildFirstPageParams({
|
||||
...apiDefaultParams,
|
||||
limit: 10,
|
||||
limit: 20,
|
||||
});
|
||||
filterFormReset(mergeTenderFilterFormState(cleared));
|
||||
router.push(pathName);
|
||||
@@ -629,11 +618,12 @@ const useTenderListPresenter = () => {
|
||||
tendersList,
|
||||
navigateToTenderDetails,
|
||||
navigateToTenderFeedback,
|
||||
handlePaginationChange: handleWindowedPaginationChange,
|
||||
buildWindowedPageLabel,
|
||||
handlePaginationChange,
|
||||
hasNext,
|
||||
hasPrevious,
|
||||
getRowNumber,
|
||||
formatDateTimeCell,
|
||||
pagination: windowedPagination,
|
||||
pagination,
|
||||
absoluteCurrentPage: pagination.currentPage,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -1,284 +1,35 @@
|
||||
"use client";
|
||||
import { PasswordIcon } from "@/assets/icons";
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
import {
|
||||
createPhoneFieldRules,
|
||||
PhoneNumberInput,
|
||||
} from "@/components/FormElements/PhoneNumberInput";
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
import FileImageUploadCard from "@/components/ui/FileImageUploadCard";
|
||||
|
||||
import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
|
||||
import FormFooter from "@/components/ui/FormFooter";
|
||||
import { FormErrorMessages } from "@/constants/Texts";
|
||||
import { ICreateAdminCredentials } from "@/lib/api";
|
||||
import { FC } from "react";
|
||||
import { Controller } from "react-hook-form";
|
||||
import useCreateAdminPresenter from "./useCreateAdminPresenter";
|
||||
import type { FC } from "react";
|
||||
import useCreateAdminPresenter, {
|
||||
type CreateAdminFormValues,
|
||||
} from "./useCreateAdminPresenter";
|
||||
|
||||
interface IProps {
|
||||
editMode?: boolean;
|
||||
defaultValues?: ICreateAdminCredentials;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
|
||||
const {
|
||||
errors,
|
||||
handleSubmit,
|
||||
register,
|
||||
control,
|
||||
onSubmit,
|
||||
isUploadingImage,
|
||||
uploadProgress,
|
||||
selectedFileName,
|
||||
selectedFileSize,
|
||||
previewUrl,
|
||||
clearSelectedProfileImage,
|
||||
profileImageRegister,
|
||||
passwordFieldInputType,
|
||||
setPasswordFieldInputType,
|
||||
} = useCreateAdminPresenter({
|
||||
editMode,
|
||||
defaultValues,
|
||||
id,
|
||||
});
|
||||
const presenter = useCreateAdminPresenter({ editMode, defaultValues, id });
|
||||
const { errors, handleSubmit, register, control, onSubmit, sections } =
|
||||
presenter;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid grid-cols-1 gap-9"
|
||||
onSubmit={handleSubmit((data) => {
|
||||
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}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</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>
|
||||
<DynamicFormBuilder<CreateAdminFormValues>
|
||||
sections={sections}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
handleSubmit={handleSubmit}
|
||||
onSubmit={onSubmit}
|
||||
dataCy={editMode ? "admin-edit-form" : "admin-create-form"}
|
||||
footer={<FormFooter />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 { useIsMutating } from "@tanstack/react-query";
|
||||
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 { toast } from "react-toastify";
|
||||
import { createAdminFormSections } from "./createAdminFormSections";
|
||||
import type { CreateAdminFormValues } from "./createAdminForm.types";
|
||||
|
||||
export type { CreateAdminFormValues } from "./createAdminForm.types";
|
||||
|
||||
interface IProps {
|
||||
editMode?: boolean;
|
||||
@@ -15,10 +19,6 @@ interface IProps {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
type CreateAdminFormValues = Omit<ICreateAdminCredentials, "profile_image"> & {
|
||||
profile_image?: FileList;
|
||||
};
|
||||
|
||||
const useCreateAdminPresenter = ({
|
||||
editMode = false,
|
||||
defaultValues,
|
||||
@@ -33,8 +33,9 @@ const useCreateAdminPresenter = ({
|
||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<number>(0);
|
||||
const [uploadedProfileImageUrl, setUploadedProfileImageUrl] = useState<
|
||||
string | undefined
|
||||
string | null | undefined
|
||||
>(defaultValues?.profile_image);
|
||||
const profileImageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
// The existing image rendered as a local (authenticated) object URL — used as
|
||||
// the preview baseline so "remove/replace" can fall back to it.
|
||||
const [defaultPreviewUrl, setDefaultPreviewUrl] = useState<string | null>(
|
||||
@@ -144,8 +145,15 @@ const useCreateAdminPresenter = ({
|
||||
};
|
||||
|
||||
const clearSelectedProfileImage = () => {
|
||||
if (profileImageInputRef.current) {
|
||||
profileImageInputRef.current.value = "";
|
||||
}
|
||||
setValue("profile_image", undefined);
|
||||
resetToDefaultProfileImage();
|
||||
setSelectedFileName("");
|
||||
setSelectedFileSize(null);
|
||||
setUploadProgress(0);
|
||||
setUploadedProfileImageUrl(null);
|
||||
setPreviewUrl(null);
|
||||
clearErrors("profile_image");
|
||||
};
|
||||
|
||||
@@ -204,10 +212,17 @@ const useCreateAdminPresenter = ({
|
||||
const profileImageRegister = register("profile_image", {
|
||||
onChange: onProfileImageChange,
|
||||
});
|
||||
const profileImageFileRegister = {
|
||||
...profileImageRegister,
|
||||
ref: (element: HTMLInputElement | null) => {
|
||||
profileImageInputRef.current = element;
|
||||
profileImageRegister.ref(element);
|
||||
},
|
||||
};
|
||||
|
||||
const onSubmit: SubmitHandler<CreateAdminFormValues> = async (data) => {
|
||||
const file = data.profile_image?.[0];
|
||||
const profileImageUrl = uploadedProfileImageUrl ?? defaultValues?.profile_image;
|
||||
const profileImageUrl = uploadedProfileImageUrl;
|
||||
|
||||
if (file && !profileImageUrl) {
|
||||
toast.error("Please wait for profile image upload to finish");
|
||||
@@ -229,6 +244,34 @@ const useCreateAdminPresenter = ({
|
||||
}
|
||||
};
|
||||
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 {
|
||||
errors,
|
||||
register,
|
||||
@@ -244,9 +287,10 @@ const useCreateAdminPresenter = ({
|
||||
selectedFileSize,
|
||||
previewUrl,
|
||||
clearSelectedProfileImage,
|
||||
profileImageRegister,
|
||||
profileImageRegister: profileImageFileRegister,
|
||||
passwordFieldInputType,
|
||||
setPasswordFieldInputType,
|
||||
sections,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
"use client";
|
||||
import { GlobeIcon, MoneyIcon } from "@/assets/icons";
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
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 DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
|
||||
import FormFooter from "@/components/ui/FormFooter";
|
||||
import { REGEX } from "@/constants/regex";
|
||||
import { FormErrorMessages } from "@/constants/Texts";
|
||||
import { ICreateCompanyCredentials } from "@/lib/api/types/Company";
|
||||
import CompanyDocuments from "./documents/CompanyDocuments";
|
||||
import type { ICreateCompanyCredentials } from "@/lib/api";
|
||||
import { createCompanyFormSections } from "./companyFormSections";
|
||||
import useCreateCompanyPresenter from "./useCreateCompanyPresenter";
|
||||
|
||||
interface IProps {
|
||||
@@ -19,482 +12,55 @@ interface IProps {
|
||||
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 {
|
||||
errors,
|
||||
handleSubmit,
|
||||
onSubmit,
|
||||
register,
|
||||
control,
|
||||
watch,
|
||||
setValue,
|
||||
persistDocumentFileIds,
|
||||
companyCategories,
|
||||
} = useCreateCompanyPresenter({
|
||||
editMode,
|
||||
defaultValues,
|
||||
id,
|
||||
});
|
||||
} = useCreateCompanyPresenter({ editMode, defaultValues, id });
|
||||
|
||||
const categoryItems =
|
||||
companyCategories?.data?.categories?.map((category) => ({
|
||||
label: category.name,
|
||||
value: category.id,
|
||||
})) ?? [];
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-6" onSubmit={handleSubmit(onSubmit)}>
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-xl font-bold text-dark dark:text-white">
|
||||
{editMode ? "Edit company" : "New company"}
|
||||
</h1>
|
||||
<p className="text-sm text-dark-5 dark:text-dark-6">
|
||||
Profile, business details, address, tags, and supporting documents.
|
||||
</p>
|
||||
</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=""
|
||||
/>
|
||||
<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>
|
||||
<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">
|
||||
<h1 className="text-xl font-bold text-dark dark:text-white">
|
||||
{editMode ? "Edit company" : "New company"}
|
||||
</h1>
|
||||
<p className="text-sm text-dark-5 dark:text-dark-6">
|
||||
Profile, business details, address, tags, documents, and links.
|
||||
</p>
|
||||
</header>
|
||||
}
|
||||
footer={<FormFooter />}
|
||||
footerPlacement="form"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
|
||||
import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
|
||||
import FormFooter from "@/components/ui/FormFooter";
|
||||
import { FormErrorMessages } from "@/constants/Texts";
|
||||
import { TCreateCompanyCategoryCredentials } from "@/lib/api";
|
||||
import type { TCreateCompanyCategoryCredentials } from "@/lib/api";
|
||||
import { createCompanyCategoryFormSections } from "./companyCategoryFormSections";
|
||||
import useCreateCompanyCategoryPresenter from "./useCreateCompanyCategoryPresenter";
|
||||
|
||||
interface IProps {
|
||||
@@ -14,72 +13,20 @@ interface IProps {
|
||||
}
|
||||
|
||||
const CreateCompanyCategoryForm = ({ defaultValues, editMode, id }: IProps) => {
|
||||
const { errors, handleSubmit, onSubmit, register } =
|
||||
const { errors, handleSubmit, onSubmit, register, control } =
|
||||
useCreateCompanyCategoryPresenter({ defaultValues, editMode, id });
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid grid-cols-1 gap-9"
|
||||
data-cy="company-category-form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<ShowcaseSection
|
||||
title={editMode ? "Edit Company Category" : "Create Company Category"}
|
||||
className="grid grid-cols-1 gap-5 md:grid-cols-2"
|
||||
>
|
||||
<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>
|
||||
<DynamicFormBuilder<TCreateCompanyCategoryCredentials>
|
||||
sections={createCompanyCategoryFormSections(editMode)}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
handleSubmit={handleSubmit}
|
||||
onSubmit={onSubmit}
|
||||
dataCy="company-category-form"
|
||||
footer={<FormFooter />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<TCreateCompanyCategoryCredentials>({
|
||||
@@ -38,6 +39,7 @@ const useCreateCompanyCategoryPresenter = ({
|
||||
};
|
||||
return {
|
||||
register,
|
||||
control,
|
||||
onSubmit,
|
||||
handleSubmit,
|
||||
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;
|
||||
value: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
onRemovePersist?: (ids: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
const CompanyDocuments = ({ companyId, value, onChange }: IProps) => {
|
||||
const CompanyDocuments = ({
|
||||
companyId,
|
||||
value,
|
||||
onChange,
|
||||
onRemovePersist,
|
||||
}: IProps) => {
|
||||
const {
|
||||
isUploading,
|
||||
isBusy,
|
||||
removingFileId,
|
||||
uploadProgress,
|
||||
failed,
|
||||
errorMessage,
|
||||
onSelectFiles,
|
||||
onRemove,
|
||||
} = useCompanyDocumentsPresenter({ companyId, value, onChange });
|
||||
} = useCompanyDocumentsPresenter({
|
||||
companyId,
|
||||
value,
|
||||
onChange,
|
||||
onRemovePersist,
|
||||
});
|
||||
|
||||
const inputId = "company-documents-input";
|
||||
|
||||
return (
|
||||
<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
|
||||
id={inputId}
|
||||
type="file"
|
||||
@@ -32,14 +45,24 @@ const CompanyDocuments = ({ companyId, value, onChange }: IProps) => {
|
||||
accept={DOCUMENT_FILE_ACCEPT}
|
||||
className="sr-only"
|
||||
onChange={onSelectFiles}
|
||||
disabled={isUploading}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
<label
|
||||
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="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">
|
||||
<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"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -71,6 +94,15 @@ const CompanyDocuments = ({ companyId, value, onChange }: IProps) => {
|
||||
/>
|
||||
</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}
|
||||
</div>
|
||||
|
||||
@@ -85,7 +117,8 @@ const CompanyDocuments = ({ companyId, value, onChange }: IProps) => {
|
||||
key={fileId}
|
||||
fileId={fileId}
|
||||
onRemove={onRemove}
|
||||
disabled={isUploading}
|
||||
disabled={isBusy}
|
||||
isRemoving={removingFileId === fileId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -100,8 +133,12 @@ const CompanyDocuments = ({ companyId, value, onChange }: IProps) => {
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{failed.map((item, index) => (
|
||||
<li key={`${item.filename}-${index}`} className="text-sm text-error">
|
||||
<span className="font-medium">{item.filename}</span>: {item.error}
|
||||
<li
|
||||
key={`${item.filename}-${index}`}
|
||||
className="text-sm text-error"
|
||||
>
|
||||
<span className="font-medium">{item.filename}</span>:{" "}
|
||||
{item.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -7,14 +7,19 @@ interface IProps {
|
||||
/** When provided, a remove button is shown and invoked with the file id. */
|
||||
onRemove?: (fileId: string) => void;
|
||||
disabled?: boolean;
|
||||
isRemoving?: boolean;
|
||||
}
|
||||
|
||||
const DocumentItem = ({ fileId, onRemove, disabled }: IProps) => {
|
||||
const DocumentItem = ({ fileId, onRemove, disabled, isRemoving }: IProps) => {
|
||||
const { info, isLoading, isDownloading, download } =
|
||||
useDocumentItemPresenter(fileId);
|
||||
|
||||
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">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
@@ -28,7 +33,7 @@ const DocumentItem = ({ fileId, onRemove, disabled }: IProps) => {
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<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>
|
||||
{info?.size ? (
|
||||
<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
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{isDownloading ? "Downloading…" : "Download"}
|
||||
@@ -48,10 +53,10 @@ const DocumentItem = ({ fileId, onRemove, disabled }: IProps) => {
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
Remove
|
||||
{isRemoving ? "Removing…" : "Remove"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"use client";
|
||||
import { useUploadCompanyDocuments } from "@/hooks/queries";
|
||||
import { fileService, FailedFile } from "@/lib/api";
|
||||
import {
|
||||
MAX_DOCUMENT_FILES,
|
||||
validateDocumentFile,
|
||||
} from "@/utils/shared";
|
||||
import { MAX_DOCUMENT_FILES, validateDocumentFile } from "@/utils/shared";
|
||||
import { ChangeEvent, useState } from "react";
|
||||
|
||||
interface IProps {
|
||||
@@ -14,6 +11,8 @@ interface IProps {
|
||||
value: string[];
|
||||
/** Persists the updated document file id list back to the form. */
|
||||
onChange: (ids: string[]) => void;
|
||||
/** Persists document removal for an existing company. */
|
||||
onRemovePersist?: (ids: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,12 +27,16 @@ const useCompanyDocumentsPresenter = ({
|
||||
companyId,
|
||||
value,
|
||||
onChange,
|
||||
onRemovePersist,
|
||||
}: IProps) => {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [removingFileId, setRemovingFileId] = useState<string | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [failed, setFailed] = useState<FailedFile[]>([]);
|
||||
const [errorMessage, setErrorMessage] = useState<string | undefined>();
|
||||
|
||||
const isBusy = isUploading || removingFileId !== null;
|
||||
|
||||
const { mutateAsync: uploadToCompany } = useUploadCompanyDocuments(
|
||||
companyId ?? "",
|
||||
);
|
||||
@@ -67,6 +70,8 @@ const useCompanyDocumentsPresenter = ({
|
||||
};
|
||||
|
||||
const onSelectFiles = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (isBusy) return;
|
||||
|
||||
const input = event.target;
|
||||
const files = input.files ? Array.from(input.files) : [];
|
||||
// Reset so selecting the same file again re-triggers change.
|
||||
@@ -95,21 +100,36 @@ const useCompanyDocumentsPresenter = ({
|
||||
};
|
||||
|
||||
const onRemove = async (fileId: string) => {
|
||||
onChange(value.filter((id) => id !== fileId));
|
||||
// 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
|
||||
// form's update (PUT replaces the full document list).
|
||||
if (!companyId) {
|
||||
try {
|
||||
if (isBusy) return;
|
||||
|
||||
setRemovingFileId(fileId);
|
||||
setErrorMessage(undefined);
|
||||
|
||||
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);
|
||||
} catch (error) {
|
||||
console.error("ERROR caught while deleting orphan document:", error);
|
||||
} else {
|
||||
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 {
|
||||
isUploading,
|
||||
isBusy,
|
||||
removingFileId,
|
||||
uploadProgress,
|
||||
failed,
|
||||
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 {
|
||||
register,
|
||||
control,
|
||||
formState: { errors },
|
||||
handleSubmit,
|
||||
watch,
|
||||
@@ -29,6 +30,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
links: defaultValues?.links ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,32 +43,55 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
|
||||
}
|
||||
}, [defaultValues, setValue]);
|
||||
const router = useRouter();
|
||||
const { data: companyCategories } = useCompanyCategoriesQuery({ published: true });
|
||||
const { data: companyCategories } = useCompanyCategoriesQuery({
|
||||
published: true,
|
||||
});
|
||||
const { mutate: createCompany, isPending: isCreating } = useCreateCompany();
|
||||
const { mutate: updateCompany, isPending: isUpdating } = useUpdateCompany(
|
||||
id as string,
|
||||
);
|
||||
const { mutateAsync: updateCompanyDocuments } = useUpdateCompany(
|
||||
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,
|
||||
founded_year: optionalNumber(data.founded_year),
|
||||
employee_count: optionalNumber(data.employee_count),
|
||||
annual_revenue: optionalNumber(data.annual_revenue),
|
||||
document_file_ids: documentFileIds ?? [],
|
||||
links: (data.links ?? []).map((link) => ({
|
||||
url: link.url.trim(),
|
||||
...(link.title?.trim() ? { title: link.title.trim() } : {}),
|
||||
})),
|
||||
tags: {
|
||||
keywords: data?.tags?.keywords?.length ? data?.tags?.keywords : [],
|
||||
categories: data?.tags?.categories?.length ? data?.tags?.categories : [],
|
||||
cpv_codes: data?.tags?.cpv_codes?.length ? data?.tags?.cpv_codes : [],
|
||||
specializations: data?.tags?.specializations?.length
|
||||
? data?.tags?.specializations
|
||||
: [],
|
||||
certifications: data?.tags?.certifications?.length
|
||||
? data?.tags?.certifications
|
||||
: [],
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<ICreateCompanyCredentials> = (data) => {
|
||||
const formattedData = {
|
||||
...data,
|
||||
founded_year: data.founded_year ? +data.founded_year : 0,
|
||||
employee_count: data.employee_count ? +data.employee_count : 0,
|
||||
annual_revenue: data.annual_revenue ? +data.annual_revenue : 0,
|
||||
document_file_ids: data.document_file_ids ?? [],
|
||||
tags: {
|
||||
keywords: data?.tags?.keywords?.length ? data?.tags?.keywords : [],
|
||||
categories: data?.tags?.categories?.length
|
||||
? data?.tags?.categories
|
||||
: [],
|
||||
cpv_codes: data?.tags?.cpv_codes?.length ? data?.tags?.cpv_codes : [],
|
||||
specializations: data?.tags?.specializations?.length
|
||||
? data?.tags?.specializations
|
||||
: [],
|
||||
certifications: data?.tags?.certifications?.length
|
||||
? data?.tags?.certifications
|
||||
: [],
|
||||
},
|
||||
};
|
||||
const formattedData = formatCompanyData(data);
|
||||
if (editMode) {
|
||||
updateCompany({
|
||||
id: id as string,
|
||||
@@ -75,7 +100,18 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
|
||||
} else {
|
||||
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 = () => {
|
||||
@@ -85,6 +121,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
|
||||
return {
|
||||
errors,
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
onSubmit,
|
||||
handleCancel,
|
||||
@@ -94,6 +131,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
|
||||
router,
|
||||
watch,
|
||||
setValue,
|
||||
persistDocumentFileIds,
|
||||
companyCategories,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { EyeIcon, GlobeIcon } from "@/assets/icons";
|
||||
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 DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
|
||||
import FormFooter from "@/components/ui/FormFooter";
|
||||
import { AdminRoles } from "@/constants/enums";
|
||||
import { REGEX } from "@/constants/regex";
|
||||
import { FormErrorMessages } from "@/constants/Texts";
|
||||
import { CreateCustomerCredentials } from "@/lib/api";
|
||||
import { getEnumAsArray } from "@/utils/shared";
|
||||
import { Controller } from "react-hook-form";
|
||||
import type { CreateCustomerCredentials } from "@/lib/api";
|
||||
import { createCustomerFormSections } from "./customerFormSections";
|
||||
import useCreateCustomerPresenter from "./useCreateCustomerPresenter";
|
||||
|
||||
interface IProps {
|
||||
@@ -37,202 +25,28 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
|
||||
setShowPassword,
|
||||
} = useCreateCustomerPresenter({ defaultValues, editMode, id });
|
||||
|
||||
const companyItems =
|
||||
companies?.map((company) => ({
|
||||
value: company.id,
|
||||
label: company.name,
|
||||
})) ?? [];
|
||||
|
||||
return (
|
||||
<form className="grid grid-cols-1 gap-9" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ShowcaseSection
|
||||
title="Create Customer"
|
||||
className="grid grid-cols-1 gap-5 md:grid-cols-2"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<InputGroup
|
||||
{...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"
|
||||
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}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</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>
|
||||
<DynamicFormBuilder<CreateCustomerCredentials>
|
||||
sections={createCustomerFormSections({
|
||||
editMode,
|
||||
companyItems,
|
||||
watch,
|
||||
showPassword,
|
||||
setShowPassword,
|
||||
})}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
handleSubmit={handleSubmit}
|
||||
onSubmit={onSubmit}
|
||||
footer={<FormFooter />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
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 DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
|
||||
import FormFooter from "@/components/ui/FormFooter";
|
||||
import { REGEX } from "@/constants/regex";
|
||||
import { FormErrorMessages } from "@/constants/Texts";
|
||||
import type { TCreateNotificationCredentials } from "@/lib/api/types/NotificationHistory";
|
||||
import { createNotificationFormSections } from "./notificationFormSections";
|
||||
import useCreateNotificationFormPresenter from "./useCreateNotificationFormPresenter";
|
||||
|
||||
const CreateNotificationForm = () => {
|
||||
@@ -29,157 +25,29 @@ const CreateNotificationForm = () => {
|
||||
types,
|
||||
channels,
|
||||
} = 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">
|
||||
<MultiSelect
|
||||
disabled={!recipient}
|
||||
required={!!recipient}
|
||||
{...register("recipient")}
|
||||
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}
|
||||
/>
|
||||
</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"
|
||||
label="Link"
|
||||
type="url"
|
||||
/>
|
||||
{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>
|
||||
return (
|
||||
<DynamicFormBuilder<TCreateNotificationCredentials>
|
||||
sections={createNotificationFormSections({
|
||||
recipient,
|
||||
onSelectTarget,
|
||||
onSearchRecipient,
|
||||
onLoadMoreRecipients,
|
||||
hasMoreRecipients,
|
||||
isLoadingMoreRecipients,
|
||||
isLoadingRecipients,
|
||||
priorities,
|
||||
targets,
|
||||
types,
|
||||
channels,
|
||||
})}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
handleSubmit={handleSubmit}
|
||||
onSubmit={onSubmit}
|
||||
footer={<FormFooter />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -4,8 +4,9 @@ export function Logo() {
|
||||
return (
|
||||
<Image
|
||||
src="/images/main-logo.svg"
|
||||
width={174}
|
||||
height={32}
|
||||
width={1217}
|
||||
height={192}
|
||||
className="h-auto w-[174px]"
|
||||
alt="Opp lens logo"
|
||||
role="presentation"
|
||||
quality={100}
|
||||
|
||||
@@ -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";
|
||||
@@ -25,6 +25,7 @@ interface UserContextType {
|
||||
setAuthState: (data: ILoginResponse) => void;
|
||||
logout: () => void;
|
||||
setUser: Dispatch<SetStateAction<IUser | null>>;
|
||||
syncCurrentUser: (updates: Partial<IUser>) => void;
|
||||
}
|
||||
|
||||
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));
|
||||
}, []);
|
||||
|
||||
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(
|
||||
() => ({
|
||||
user,
|
||||
@@ -108,8 +119,9 @@ export const UserProvider: React.FC<UserProviderProps> = ({ children }) => {
|
||||
setAuthState,
|
||||
logout,
|
||||
setUser,
|
||||
syncCurrentUser,
|
||||
}),
|
||||
[user, accessToken, isLoading, setAuthState, logout, setUser],
|
||||
[user, accessToken, isLoading, setAuthState, logout, setUser, syncCurrentUser],
|
||||
);
|
||||
|
||||
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
|
||||
|
||||
@@ -13,7 +13,7 @@ export * from "./useNotificationQueries";
|
||||
export * from "./useProfileQueries";
|
||||
export * from "./useTenders";
|
||||
export * from "./useUsersQueries";
|
||||
export * from "./useAdminListQuery";
|
||||
export * from "./useAdminQueries";
|
||||
export * from "./useSelectSearchQuery";
|
||||
export * from "./useDashboardQueries";
|
||||
export * from "./useFilesQueries";
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
@@ -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],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
API_ENDPOINTS,
|
||||
companiesService,
|
||||
companyCategoriesService,
|
||||
type ICompanyLink,
|
||||
type TCompanyCategoryApiResponse,
|
||||
} from "@/lib/api";
|
||||
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 router = useRouter();
|
||||
const mutationKey = useMemo(
|
||||
@@ -42,14 +54,21 @@ export const useUpdateCompany = (id: string) => {
|
||||
mutationKey,
|
||||
mutationFn: companiesService.updateCompany,
|
||||
onSuccess: () => {
|
||||
toast.success("Company updated successfully");
|
||||
if (showSuccessToast) {
|
||||
toast.success("Company updated successfully");
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL, "details"],
|
||||
});
|
||||
router.push("/companies");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL, id, "details"],
|
||||
});
|
||||
if (redirectOnSuccess) {
|
||||
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) => {
|
||||
const queryClient = useQueryClient();
|
||||
const mutationKey = useMemo(
|
||||
@@ -206,30 +245,33 @@ export const useDeleteCompanyCategoryQuery = (successCallback?: () => void) => {
|
||||
};
|
||||
export const useToggleCompanyCategoryPublished = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const updateOptimisticCategories = useCallback((categoryId: string) => {
|
||||
queryClient.setQueriesData<TCompanyCategoryApiResponse>(
|
||||
{
|
||||
queryKey: [API_ENDPOINTS.COMPANIES.CATEGORIES.READ_ALL],
|
||||
exact: false,
|
||||
},
|
||||
(previousList) => {
|
||||
if (!previousList?.data?.categories) {
|
||||
return previousList;
|
||||
}
|
||||
return {
|
||||
...previousList,
|
||||
data: {
|
||||
...previousList.data,
|
||||
categories: previousList.data.categories.map((category) =>
|
||||
category.id === categoryId
|
||||
? { ...category, published: !category.published }
|
||||
: category,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}, [queryClient]);
|
||||
const updateOptimisticCategories = useCallback(
|
||||
(categoryId: string) => {
|
||||
queryClient.setQueriesData<TCompanyCategoryApiResponse>(
|
||||
{
|
||||
queryKey: [API_ENDPOINTS.COMPANIES.CATEGORIES.READ_ALL],
|
||||
exact: false,
|
||||
},
|
||||
(previousList) => {
|
||||
if (!previousList?.data?.categories) {
|
||||
return previousList;
|
||||
}
|
||||
return {
|
||||
...previousList,
|
||||
data: {
|
||||
...previousList.data,
|
||||
categories: previousList.data.categories.map((category) =>
|
||||
category.id === categoryId
|
||||
? { ...category, published: !category.published }
|
||||
: category,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
const mutationKey = useMemo(
|
||||
() => [
|
||||
API_ENDPOINTS.COMPANIES.CATEGORIES.TOGGLE_PUBLISHED("published"),
|
||||
@@ -245,8 +287,7 @@ export const useToggleCompanyCategoryPublished = () => {
|
||||
data?: { message?: string };
|
||||
message?: string;
|
||||
};
|
||||
const toastMessage =
|
||||
responseBody.data?.message ?? responseBody.message;
|
||||
const toastMessage = responseBody.data?.message ?? responseBody.message;
|
||||
if (toastMessage) {
|
||||
toast.success(toastMessage);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo } from "react";
|
||||
|
||||
export const useDashboardSummaryQuery = (params?: {
|
||||
closing_window?: number;
|
||||
days?: number;
|
||||
}) => {
|
||||
const queryKey = useMemo(
|
||||
() => [API_ENDPOINTS.DASHBOARD.SUMMARY, params],
|
||||
|
||||
@@ -70,6 +70,10 @@ export const useCreateNotificationQuery = (successCallback?: () => void) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [API_ENDPOINTS.NOTIFICATIONS.MY_NOTIFICATIONS],
|
||||
});
|
||||
queryClient.refetchQueries({
|
||||
queryKey: [API_ENDPOINTS.NOTIFICATIONS.MY_NOTIFICATIONS],
|
||||
type: "active",
|
||||
});
|
||||
if (successCallback) {
|
||||
successCallback();
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
};
|
||||
@@ -14,7 +14,7 @@ export const useProfileQuery = () => {
|
||||
});
|
||||
};
|
||||
export const useUpdateProfileQuery = () => {
|
||||
const { setUser } = useUser();
|
||||
const { syncCurrentUser } = useUser();
|
||||
const queryClient = useQueryClient();
|
||||
const mutationKey = useMemo(
|
||||
() => [API_ENDPOINTS.PROFILE.UPDATE, "UPDATE_USER_PROFILE"],
|
||||
@@ -28,7 +28,7 @@ export const useUpdateProfileQuery = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [API_ENDPOINTS.PROFILE.READ, "CURRENT_USER_PROFILE"],
|
||||
});
|
||||
setUser(response);
|
||||
syncCurrentUser(response);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { API_ENDPOINTS, tendersService } from "@/lib/api";
|
||||
import { TTenderListParams } from "@/lib/api/types/Tenders";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const useTendersQuery = (params?: Record<string, any>) => {
|
||||
export const useTendersQuery = (params?: TTenderListParams) => {
|
||||
const queryKey = useMemo(
|
||||
() => [API_ENDPOINTS.TENDERS.READ_ALL, "read-tenders", 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(
|
||||
() => [API_ENDPOINTS.TENDERS.DETAILS(id), params],
|
||||
[id, params],
|
||||
@@ -52,13 +56,10 @@ export const useGetTenderDocumentsQuery = (
|
||||
id: string,
|
||||
options?: { enabled?: boolean },
|
||||
) => {
|
||||
const queryKey = useMemo(
|
||||
() => [API_ENDPOINTS.TENDERS.DOCUMENTS(id)],
|
||||
[id],
|
||||
);
|
||||
const queryKey = useMemo(() => [API_ENDPOINTS.TENDERS.DOCUMENTS(id)], [id]);
|
||||
return useQuery({
|
||||
queryKey,
|
||||
queryFn: () => tendersService.getDocuments(id),
|
||||
enabled: options?.enabled ?? true,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import { useUser } from "@/contexts";
|
||||
import { API_ENDPOINTS, userService } from "@/lib/api";
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const useLoginQuery = (callback?: () => void) => {
|
||||
const mutationKey = useMemo(() => [API_ENDPOINTS.USER.LOGIN], []);
|
||||
const { setAuthState } = useUser();
|
||||
const router = useRouter();
|
||||
return useMutation({
|
||||
mutationKey,
|
||||
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 = () => {
|
||||
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({
|
||||
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 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],
|
||||
});
|
||||
mutationFn: () => userService.logout(),
|
||||
onSuccess: () => {
|
||||
logout();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { IMetaData } from "@/lib/api/types/Factory";
|
||||
import { AxiosError } from "axios";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
@@ -12,7 +11,14 @@ const isInvalidCursorError = (error: unknown) => {
|
||||
};
|
||||
|
||||
type UseHybridPaginationOptions = {
|
||||
meta?: IMetaData;
|
||||
meta?: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
page?: number;
|
||||
pages?: number;
|
||||
has_more?: boolean;
|
||||
next_cursor?: string;
|
||||
};
|
||||
params: Record<string, unknown>;
|
||||
setParams: React.Dispatch<React.SetStateAction<Record<string, any>>>;
|
||||
isError?: boolean;
|
||||
@@ -74,7 +80,7 @@ export const useHybridPagination = ({
|
||||
|
||||
if (cursorStack.length > 0) {
|
||||
const last = cursorStack[cursorStack.length - 1];
|
||||
return Math.max(0, last.fromPage - 1 + cursorStack.length);
|
||||
return Math.max(0, last.fromPage);
|
||||
}
|
||||
|
||||
const paramsCursor =
|
||||
@@ -91,7 +97,14 @@ export const useHybridPagination = ({
|
||||
|
||||
if (meta?.page != null) return meta.page - 1;
|
||||
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;
|
||||
|
||||
@@ -129,7 +142,11 @@ export const useHybridPagination = ({
|
||||
{ cursor: nextCursor, fromPage: fromPage1Based },
|
||||
]);
|
||||
setParams((current) => {
|
||||
const next: Record<string, any> = { ...current, cursor: nextCursor, limit };
|
||||
const next: Record<string, any> = {
|
||||
...current,
|
||||
cursor: nextCursor,
|
||||
limit,
|
||||
};
|
||||
delete next.offset;
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ export const API_ENDPOINTS = {
|
||||
DELETE: (id: string) => `companies/${id}`,
|
||||
DETAILS: (id: string) => `companies/${id}`,
|
||||
DOCUMENTS: (id: string) => `companies/${id}/documents`,
|
||||
LINKS: (id: string) => `companies/${id}/links`,
|
||||
CATEGORIES: {
|
||||
READ_ALL: "company-categories",
|
||||
CREATE: "company-categories",
|
||||
@@ -92,4 +93,7 @@ export const API_ENDPOINTS = {
|
||||
CLOSING_SOON: "dashboard/closing-soon",
|
||||
RECENT: "dashboard/recent",
|
||||
},
|
||||
PORTALS: {
|
||||
GET: "ai-pipeline/scrape/portals",
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
TCompaniesResponse,
|
||||
TCompanyCategoryApiResponse,
|
||||
TCreateCompanyCategoryCredentials,
|
||||
IAddCompanyLinksResponse,
|
||||
ICompanyLink,
|
||||
UploadDocumentsData,
|
||||
} from "../types";
|
||||
|
||||
@@ -87,10 +89,25 @@ export const companiesService = {
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("ERROR caught in Companies Services Upload Documents:", error);
|
||||
console.error(
|
||||
"ERROR caught in Companies Services Upload Documents:",
|
||||
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) => {
|
||||
try {
|
||||
return (await api.delete(API_ENDPOINTS.COMPANIES.DELETE(id))).data;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
export const dashboardService = {
|
||||
summary: async (params?: {
|
||||
closing_window?: number;
|
||||
days?: number;
|
||||
}): Promise<ApiResponse<TDashboardSummary>> => {
|
||||
try {
|
||||
return (await api.get(API_ENDPOINTS.DASHBOARD.SUMMARY, { params })).data;
|
||||
|
||||
@@ -2,6 +2,8 @@ export * from "./companies-service";
|
||||
export * from "./dashboard-service";
|
||||
export * from "./file-service";
|
||||
export * from "./inquiries-service";
|
||||
export * from "./portals-service";
|
||||
export * from "./profile-service";
|
||||
export * from "./tenders-service";
|
||||
export * from "./user-services";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -5,14 +5,16 @@ import { ApiResponse } from "../types";
|
||||
import {
|
||||
TTenderDetails,
|
||||
TTenderDocumentsResponse,
|
||||
TTenderListParams,
|
||||
TTenderListMeta,
|
||||
TTenderResponse,
|
||||
TTenderTranslationResponse,
|
||||
} from "../types/Tenders";
|
||||
|
||||
export const tendersService = {
|
||||
tendersList: async (
|
||||
params?: Record<string, any>,
|
||||
): Promise<ApiResponse<TTenderResponse>> => {
|
||||
params?: TTenderListParams,
|
||||
): Promise<ApiResponse<TTenderResponse, TTenderListMeta>> => {
|
||||
try {
|
||||
return (
|
||||
await api.get(API_ENDPOINTS.TENDERS.READ_ALL, {
|
||||
@@ -30,7 +32,8 @@ export const tendersService = {
|
||||
params?: Record<string, any>,
|
||||
): Promise<ApiResponse<TTenderDetails>> => {
|
||||
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) {
|
||||
console.error(
|
||||
"ERROR caught in Tender Service => tender details: ",
|
||||
@@ -53,12 +56,17 @@ export const tendersService = {
|
||||
})
|
||||
).data;
|
||||
} catch (error) {
|
||||
console.error("ERROR caught in Tender Service => translate tender: ", error);
|
||||
console.error(
|
||||
"ERROR caught in Tender Service => translate tender: ",
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
getDocuments: async (id: string): Promise<ApiResponse<TTenderDocumentsResponse[]>> => {
|
||||
getDocuments: async (
|
||||
id: string,
|
||||
): Promise<ApiResponse<TTenderDocumentsResponse[]>> => {
|
||||
try {
|
||||
return (await api.get(API_ENDPOINTS.TENDERS.DOCUMENTS(id))).data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -28,6 +28,11 @@ export interface ITags {
|
||||
cpv_codes: string[];
|
||||
}
|
||||
|
||||
export interface ICompanyLink {
|
||||
url: string;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
export interface ICreateCompanyCredentials {
|
||||
name: string;
|
||||
email: string;
|
||||
@@ -35,11 +40,11 @@ export interface ICreateCompanyCredentials {
|
||||
website: string;
|
||||
type: string;
|
||||
description: string;
|
||||
founded_year: number;
|
||||
employee_count: number;
|
||||
annual_revenue: number;
|
||||
founded_year?: number | string;
|
||||
employee_count?: number | string;
|
||||
annual_revenue?: number | string;
|
||||
currency: string;
|
||||
industry: string;
|
||||
industry?: string;
|
||||
language: string;
|
||||
tax_id: string;
|
||||
registration_number: string;
|
||||
@@ -48,6 +53,7 @@ export interface ICreateCompanyCredentials {
|
||||
contact_person: IContactPerson;
|
||||
tags: ITags;
|
||||
document_file_ids?: string[];
|
||||
links?: ICompanyLink[];
|
||||
}
|
||||
|
||||
export const companySchema = z.object({
|
||||
@@ -62,7 +68,7 @@ export const companySchema = z.object({
|
||||
employee_count: z.number(),
|
||||
annual_revenue: z.number(),
|
||||
currency: z.string(),
|
||||
industry: z.string(),
|
||||
industry: z.string().optional(),
|
||||
language: z.string(),
|
||||
tax_id: z.string(),
|
||||
registration_number: z.string(),
|
||||
@@ -92,6 +98,14 @@ export const companySchema = z.object({
|
||||
cpv_codes: 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({
|
||||
@@ -104,7 +118,7 @@ export const companiesResponseSchema = z.object({
|
||||
export const companyDetailsApiResponseSchema =
|
||||
createApiResponseSchema(companySchema);
|
||||
export const CompaniesListResponseSchema = createApiResponseSchema(
|
||||
companiesResponseSchema
|
||||
companiesResponseSchema,
|
||||
);
|
||||
export type TCompany = z.infer<typeof companySchema>;
|
||||
export type TCompaniesResponse = z.infer<typeof companiesResponseSchema>;
|
||||
|
||||
@@ -7,6 +7,8 @@ export type TDashboardSummary = {
|
||||
closing_soon: number;
|
||||
total_estimated_value: number;
|
||||
value_currency: string;
|
||||
days: number;
|
||||
scraped_ted: TDashboardTrendPoint[];
|
||||
generated_at: number;
|
||||
};
|
||||
|
||||
@@ -22,7 +24,6 @@ export type TDashboardTrend = {
|
||||
};
|
||||
|
||||
export type TDashboardStatisticsDaily = {
|
||||
scraped_ted: TDashboardTrendPoint[];
|
||||
scraped_documents: TDashboardTrendPoint[];
|
||||
translated_notices: TDashboardTrendPoint[];
|
||||
};
|
||||
@@ -30,6 +31,7 @@ export type TDashboardStatisticsDaily = {
|
||||
export type TDashboardStatisticsTotals = {
|
||||
scraped_documents: number;
|
||||
translated_tenders: number;
|
||||
scraped_ted_notices: number;
|
||||
};
|
||||
|
||||
export type TDashboardStatistics = {
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
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 {
|
||||
street: string;
|
||||
city: string;
|
||||
@@ -28,6 +38,16 @@ export interface ITags {
|
||||
cpv_codes: string[];
|
||||
}
|
||||
|
||||
export interface ICompanyLink {
|
||||
url: string;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
export interface IAddCompanyLinksResponse {
|
||||
links: ICompanyLink[];
|
||||
added: ICompanyLink[];
|
||||
}
|
||||
|
||||
export interface ICreateCompanyCredentials {
|
||||
name: string;
|
||||
email: string;
|
||||
@@ -35,11 +55,11 @@ export interface ICreateCompanyCredentials {
|
||||
website: string;
|
||||
type: string;
|
||||
description: string;
|
||||
founded_year: number;
|
||||
employee_count: number;
|
||||
annual_revenue: number;
|
||||
founded_year?: number | string;
|
||||
employee_count?: number | string;
|
||||
annual_revenue?: number | string;
|
||||
currency: string;
|
||||
industry: string;
|
||||
industry?: string;
|
||||
language: string;
|
||||
tax_id: string;
|
||||
registration_number: string;
|
||||
@@ -48,6 +68,7 @@ export interface ICreateCompanyCredentials {
|
||||
contact_person?: IContactPerson;
|
||||
tags: ITags;
|
||||
document_file_ids?: string[];
|
||||
links?: ICompanyLink[];
|
||||
}
|
||||
|
||||
export const companySchema = z.object({
|
||||
@@ -57,16 +78,21 @@ export const companySchema = z.object({
|
||||
phone: z.string().optional(),
|
||||
website: z.string(),
|
||||
type: z.string(),
|
||||
status: z.string().optional(),
|
||||
description: z.string(),
|
||||
founded_year: z.number(),
|
||||
employee_count: z.number(),
|
||||
annual_revenue: z.number(),
|
||||
founded_year: z.number().nullish(),
|
||||
employee_count: z.number().nullish(),
|
||||
annual_revenue: z.number().nullish(),
|
||||
currency: z.string(),
|
||||
industry: z.string(),
|
||||
industry: z.string().optional(),
|
||||
language: z.string(),
|
||||
tax_id: z.string(),
|
||||
registration_number: 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({
|
||||
street: z.string(),
|
||||
city: z.string(),
|
||||
@@ -85,15 +111,25 @@ export const companySchema = z.object({
|
||||
position: z.string(),
|
||||
is_primary: z.boolean(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
tags: z.object({
|
||||
keywords: z.array(z.string()).or(z.null()).optional(),
|
||||
categories: z.array(z.any()).or(z.null()).optional(),
|
||||
specializations: z.array(z.string()).or(z.null()).optional(),
|
||||
certifications: z.array(z.string()).or(z.null()).optional(),
|
||||
cpv_codes: z.array(z.string()).or(z.null()).optional(),
|
||||
keywords: nullableStringArraySchema,
|
||||
categories: nullableArraySchema,
|
||||
specializations: nullableStringArraySchema,
|
||||
certifications: nullableStringArraySchema,
|
||||
cpv_codes: nullableStringArraySchema,
|
||||
}),
|
||||
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({
|
||||
|
||||
@@ -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>;
|
||||
@@ -123,9 +123,33 @@ export const TendersResponse = z.object({
|
||||
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 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<
|
||||
typeof TenderTranslationResponseSchema
|
||||
>;
|
||||
|
||||
@@ -41,7 +41,7 @@ export const CreateAdminSchema = z.object({
|
||||
.regex(/\d/, "Password must contain at least one number")
|
||||
.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"),
|
||||
position: z.string().min(1, "Position is required"),
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
export * from "./CompanyCategories";
|
||||
export * from "./CompanyDocuments";
|
||||
export * from "./Customers";
|
||||
export * from "./Dashboard";
|
||||
export * from "./Factory";
|
||||
export * from "./Feedback";
|
||||
export * from "./Posts";
|
||||
export * from "./Profile";
|
||||
export * from "./shared";
|
||||
export * from "./TCompany";
|
||||
export * from "./User";
|
||||
export * from "./TInquiries";
|
||||
export * from "./TCms";
|
||||
export * from "./Feedback";
|
||||
export * from "./TCompany";
|
||||
export * from "./Tenders";
|
||||
export * from "./Dashboard";
|
||||
export * from "./TInquiries";
|
||||
export * from "./TPortals";
|
||||
export * from "./User";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { IMetaData } from "./Factory";
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
export interface ApiResponse<T, TMeta = IMetaData> {
|
||||
data: T;
|
||||
message: string;
|
||||
code?: number;
|
||||
meta?: IMetaData;
|
||||
meta?: TMeta;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user