Compare commits
40 Commits
eb41f9b7b2
..
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 | |||
| a62963a93d | |||
| 72948a9b55 | |||
| da1e2f89e7 | |||
| 88f223bf08 | |||
| 5d01d66ea3 | |||
| a99f86b2f9 | |||
| bb3695ee74 | |||
| 9424758af5 | |||
| de0e31bac5 | |||
| f0902d7536 |
+42
-38
@@ -1,60 +1,64 @@
|
|||||||
################################################################################
|
|
||||||
# web
|
|
||||||
################################################################################
|
|
||||||
kind: pipeline
|
kind: pipeline
|
||||||
type: docker
|
type: docker
|
||||||
name: (admin Panel) build form and push to artifactory
|
name: build-and-push-panel
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: build node app
|
- name: build node app
|
||||||
image: docker-mirror.ravanertebat.ir/base/node/node-cypress4:22.14
|
image: cypress/included:14.5.4
|
||||||
|
|
||||||
environment:
|
|
||||||
PNPM_CONFIG_REGISTRY: https://mirror.ravanertebat.ir/repository/npm-proxy/
|
|
||||||
#NPM_CONFIG_STRICT_SSL: false
|
|
||||||
#NPM_CONFIG_AUDIT: false
|
|
||||||
commands:
|
commands:
|
||||||
- |
|
- |
|
||||||
set -e
|
set -e
|
||||||
case "$DRONE_BRANCH" in
|
SHORT_SHA=$(echo "$DRONE_COMMIT_SHA" | cut -c1-8)
|
||||||
pro) echo prod;;
|
IMG_TAG="${DRONE_BUILD_NUMBER}-${DRONE_BRANCH}"
|
||||||
uat) echo uat;;
|
echo -n "$IMG_TAG,$SHORT_SHA" > .tags
|
||||||
test) echo test;;
|
echo "Image tags -> $IMG_TAG , $SHORT_SHA"
|
||||||
*) echo dev;;
|
- corepack enable
|
||||||
esac > .environment
|
- corepack prepare pnpm@10.18.0 --activate
|
||||||
- echo "Branch ${DRONE_BRANCH}, Environment $(cat .environment)"
|
|
||||||
- APP_VERSION=$(cat .environment)-${DRONE_BUILD_NUMBER}
|
|
||||||
- echo "APP Version tags $APP_VERSION"
|
|
||||||
|
|
||||||
- mkdir -p ./artifacts/node
|
- mkdir -p ./artifacts/node
|
||||||
- rm -rf .git
|
- rm -rf .git dist
|
||||||
# - corepack enable
|
|
||||||
# - corepack prepare pnpm@10.18.0 --activate
|
|
||||||
# - pnpm config list
|
|
||||||
- rm -rf dist
|
|
||||||
- pnpm install --frozen-lockfile --reporter=append-only
|
- pnpm install --frozen-lockfile --reporter=append-only
|
||||||
- pnpm run test:e2e
|
|
||||||
- pnpm run build
|
- pnpm run build
|
||||||
- cp -R .next/standalone/. ./artifacts/node/
|
- cp -R .next/standalone/. ./artifacts/node/
|
||||||
- ls -ltrha ./artifacts/node
|
- ls -ltrha ./artifacts/node
|
||||||
|
|
||||||
- name: build panel
|
- name: push panel
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
# build_args:
|
registry: git.opplens.se
|
||||||
# - BUILD_APP=scraper
|
repo: git.opplens.se/mazyar/tm-panel
|
||||||
username: cicd
|
username:
|
||||||
|
from_secret: registry_username
|
||||||
password:
|
password:
|
||||||
from_secret: REGISTRY_PASSWORD
|
from_secret: registry_password
|
||||||
# dockerfile: Dockerfile-drone
|
|
||||||
repo: artifactory.ravanertebat.ir/tm/admin_panel
|
- name: deploy-uat
|
||||||
registry: artifactory.ravanertebat.ir
|
image: appleboy/drone-ssh
|
||||||
|
settings:
|
||||||
|
host: 10.0.0.3
|
||||||
|
username: mazyar
|
||||||
|
port: 22
|
||||||
|
key:
|
||||||
|
from_secret: ssh_deploy_key
|
||||||
|
envs:
|
||||||
|
- DRONE_BUILD_NUMBER
|
||||||
|
- DRONE_BRANCH
|
||||||
|
script:
|
||||||
|
- set -e
|
||||||
|
- cd /data/tm-uat
|
||||||
|
- NEW_TAG="${DRONE_BUILD_NUMBER}-${DRONE_BRANCH}"
|
||||||
|
- echo "Deploying panel tag $NEW_TAG"
|
||||||
|
- sed -i "s/^PANEL_IMG_TAG=.*/PANEL_IMG_TAG=$NEW_TAG/" .env
|
||||||
|
- docker compose pull panel
|
||||||
|
- docker compose up -d panel
|
||||||
|
- docker image prune -f
|
||||||
|
when:
|
||||||
|
branch:
|
||||||
|
- dev
|
||||||
|
|
||||||
node:
|
|
||||||
tag: aecde-docker-runner
|
|
||||||
trigger:
|
trigger:
|
||||||
branch:
|
branch:
|
||||||
- main
|
- main
|
||||||
- develop
|
- uat
|
||||||
|
- dev
|
||||||
event:
|
event:
|
||||||
- custom
|
- push
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
NEXT_PUBLIC_APP_VERSION=2.2.5
|
NEXT_PUBLIC_APP_VERSION=2.3.3
|
||||||
NEXT_PUBLIC_TOAST_AUTO_CLOSE_DURATION=2500
|
NEXT_PUBLIC_TOAST_AUTO_CLOSE_DURATION=2500
|
||||||
NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0
|
NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0
|
||||||
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com
|
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com
|
||||||
|
|||||||
@@ -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".
|
||||||
@@ -11,6 +11,12 @@ COPY package.json pnpm-lock.yaml ./
|
|||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
# next.config.mjs evaluates rewrites() at build time, so the optional
|
||||||
|
# BACKEND_API_BASE_URL override must be available here to be baked into the
|
||||||
|
# routes manifest. Left unset, the host-based routing (opplens.com vs
|
||||||
|
# opplenz.com) applies and no env is needed.
|
||||||
|
ARG BACKEND_API_BASE_URL
|
||||||
|
ENV BACKEND_API_BASE_URL=${BACKEND_API_BASE_URL}
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|||||||
@@ -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).
|
||||||
+25
-1
@@ -34,10 +34,34 @@ const nextConfig = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
|
// The same build is deployed to two domains, each with its own backend:
|
||||||
|
// - test: opplenz.com -> https://admin.opplenz.com/admin/v1
|
||||||
|
// - production: opplens.com -> https://admin.opplens.com/admin/v1
|
||||||
|
// Routing is host-based so neither deploy needs a custom env. A
|
||||||
|
// `BACKEND_API_BASE_URL` env var (if set) overrides both — handy for local
|
||||||
|
// dev pointing at e.g. http://localhost:8080/admin/v1.
|
||||||
|
// ! ⚠️ IMPORTANT: Adding BACKEND_API_BASE_URL to env file will break the multi-domain feature.
|
||||||
|
const override = process.env.BACKEND_API_BASE_URL;
|
||||||
|
if (override) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: "/api/proxy/:path*",
|
source: "/api/proxy/:path*",
|
||||||
destination: `https://admin.opplenz.com/admin/v1/:path*`,
|
destination: `${override}/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
// Production: any opplens.com host (apex or subdomain like www.).
|
||||||
|
{
|
||||||
|
source: "/api/proxy/:path*",
|
||||||
|
has: [{ type: "host", value: "(.*\\.)?opplens\\.com" }],
|
||||||
|
destination: "https://admin.opplens.com/admin/v1/:path*",
|
||||||
|
},
|
||||||
|
// Default (test opplenz.com + local dev).
|
||||||
|
{
|
||||||
|
source: "/api/proxy/:path*",
|
||||||
|
destination: "https://admin.opplenz.com/admin/v1/:path*",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
"react-tooltip": "^5.29.1",
|
"react-tooltip": "^5.29.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"uuid": "^14.0.1",
|
||||||
"zod": "^4.1.5"
|
"zod": "^4.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Generated
+9
@@ -104,6 +104,9 @@ importers:
|
|||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^2.6.0
|
specifier: ^2.6.0
|
||||||
version: 2.6.1
|
version: 2.6.1
|
||||||
|
uuid:
|
||||||
|
specifier: ^14.0.1
|
||||||
|
version: 14.0.1
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.1.5
|
specifier: ^4.1.5
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
@@ -2977,6 +2980,10 @@ packages:
|
|||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
|
uuid@14.0.1:
|
||||||
|
resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
uuid@8.3.2:
|
uuid@8.3.2:
|
||||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -6218,6 +6225,8 @@ snapshots:
|
|||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
|
uuid@14.0.1: {}
|
||||||
|
|
||||||
uuid@8.3.2: {}
|
uuid@8.3.2: {}
|
||||||
|
|
||||||
verror@1.10.0:
|
verror@1.10.0:
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import Loading from "@/components/loading";
|
import Loading from "@/components/loading";
|
||||||
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
|
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
|
||||||
|
import DocumentItem from "@/components/forms/companies/documents/DocumentItem";
|
||||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||||
import Status from "@/components/ui/Status";
|
import Status from "@/components/ui/Status";
|
||||||
import { useCompanyDetails } from "@/hooks/queries";
|
import { useCompanyDetails } from "@/hooks/queries";
|
||||||
|
import type { ICompanyLink } from "@/lib/api";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { BreadcrumbItem } from "@/types/shared";
|
import { BreadcrumbItem } from "@/types/shared";
|
||||||
import { formatPhoneNumber, unixToDate } from "@/utils/shared";
|
import { formatPhoneNumber, unixToDate } from "@/utils/shared";
|
||||||
@@ -72,6 +74,12 @@ const CompanyDetailsPage = ({ params }: IProps) => {
|
|||||||
if (isLoading) return <Loading />;
|
if (isLoading) return <Loading />;
|
||||||
const company = data.data;
|
const company = data.data;
|
||||||
const website = company.website?.trim();
|
const website = company.website?.trim();
|
||||||
|
const documentFileIds: string[] = Array.isArray(company.document_file_ids)
|
||||||
|
? company.document_file_ids
|
||||||
|
: [];
|
||||||
|
const companyLinks: ICompanyLink[] = Array.isArray(company.links)
|
||||||
|
? company.links
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -141,12 +149,18 @@ const CompanyDetailsPage = ({ params }: IProps) => {
|
|||||||
{`${company.currency || "—"} ${getSymbolFromCurrency(company.currency ?? "USD")}`}
|
{`${company.currency || "—"} ${getSymbolFromCurrency(company.currency ?? "USD")}`}
|
||||||
</span>
|
</span>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
<ShowcaseSection title="Annual revenue" {...SHOWCASE_CENTERED_BODY}>
|
<ShowcaseSection
|
||||||
|
title="Annual revenue"
|
||||||
|
{...SHOWCASE_CENTERED_BODY}
|
||||||
|
>
|
||||||
<span className="font-medium text-dark dark:text-white">
|
<span className="font-medium text-dark dark:text-white">
|
||||||
{company.annual_revenue || "—"}
|
{company.annual_revenue || "—"}
|
||||||
</span>
|
</span>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
<ShowcaseSection title="Employee count" {...SHOWCASE_CENTERED_BODY}>
|
<ShowcaseSection
|
||||||
|
title="Employee count"
|
||||||
|
{...SHOWCASE_CENTERED_BODY}
|
||||||
|
>
|
||||||
<span className="font-medium text-dark dark:text-white">
|
<span className="font-medium text-dark dark:text-white">
|
||||||
{company.employee_count || "—"}
|
{company.employee_count || "—"}
|
||||||
</span>
|
</span>
|
||||||
@@ -215,6 +229,46 @@ const CompanyDetailsPage = ({ params }: IProps) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{documentFileIds.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionHeading>Documents</SectionHeading>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{documentFileIds.map((fileId) => (
|
||||||
|
<DocumentItem key={fileId} fileId={fileId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{companyLinks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionHeading>Links</SectionHeading>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{companyLinks.map((companyLink, index) => (
|
||||||
|
<Link
|
||||||
|
key={`${companyLink.url}-${index}`}
|
||||||
|
href={companyLink.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="shadow-theme-xs flex min-w-0 items-center justify-between gap-3 rounded-xl border border-stroke/70 bg-white/80 px-4 py-3 text-primary transition hover:border-primary/50 hover:bg-primary/5 dark:border-dark-3 dark:bg-dark-2/70 dark:hover:border-primary/50"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate font-medium text-dark dark:text-white">
|
||||||
|
{companyLink.title || companyLink.url}
|
||||||
|
</span>
|
||||||
|
{companyLink.title ? (
|
||||||
|
<span className="block truncate text-xs text-dark-5 dark:text-dark-6">
|
||||||
|
{companyLink.url}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<ExternalLinkIcon />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -28,19 +28,25 @@ export function ClosingSoonCard({ tenders, isLoading }: Props) {
|
|||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
const root = rootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
const reduced =
|
const reduced =
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
const ctx = gsap.context(() => {
|
const ctx = gsap.context(() => {
|
||||||
if (reduced) return;
|
if (reduced) return;
|
||||||
gsap.from(rootRef.current, {
|
gsap.from(root, {
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
y: 24,
|
y: 24,
|
||||||
duration: 0.6,
|
duration: 0.6,
|
||||||
ease: "power3.out",
|
ease: "power3.out",
|
||||||
delay: 0.3,
|
delay: 0.3,
|
||||||
});
|
});
|
||||||
gsap.from("[data-closing-row]", {
|
|
||||||
|
const rows = root.querySelectorAll("[data-closing-row]");
|
||||||
|
if (rows.length > 0) {
|
||||||
|
gsap.from(rows, {
|
||||||
x: 24,
|
x: 24,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
duration: 0.45,
|
duration: 0.45,
|
||||||
@@ -48,14 +54,15 @@ export function ClosingSoonCard({ tenders, isLoading }: Props) {
|
|||||||
delay: 0.55,
|
delay: 0.55,
|
||||||
ease: "power2.out",
|
ease: "power2.out",
|
||||||
});
|
});
|
||||||
}, rootRef);
|
}
|
||||||
|
}, root);
|
||||||
return () => ctx.revert();
|
return () => ctx.revert();
|
||||||
}, [tenders.length, isLoading]);
|
}, [tenders.length, isLoading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card"
|
className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:shadow-card dark:ring-stroke-dark"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-stroke px-5 py-4 dark:border-stroke-dark">
|
<div className="flex items-center justify-between border-b border-stroke px-5 py-4 dark:border-stroke-dark">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -66,7 +73,9 @@ export function ClosingSoonCard({ tenders, isLoading }: Props) {
|
|||||||
<h2 className="text-base font-bold text-dark dark:text-white">
|
<h2 className="text-base font-bold text-dark dark:text-white">
|
||||||
Closing Soon
|
Closing Soon
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-dark-6">Tenders with looming deadlines</p>
|
<p className="text-xs text-dark-6">
|
||||||
|
Tenders with looming deadlines
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
@@ -80,7 +89,10 @@ export function ClosingSoonCard({ tenders, isLoading }: Props) {
|
|||||||
<ul className="flex-1 divide-y divide-stroke dark:divide-stroke-dark">
|
<ul className="flex-1 divide-y divide-stroke dark:divide-stroke-dark">
|
||||||
{isLoading
|
{isLoading
|
||||||
? Array.from({ length: 5 }).map((_, i) => (
|
? Array.from({ length: 5 }).map((_, i) => (
|
||||||
<li key={i} className="flex animate-pulse items-center gap-3 px-5 py-3.5">
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex animate-pulse items-center gap-3 px-5 py-3.5"
|
||||||
|
>
|
||||||
<div className="size-10 rounded-lg bg-gray-2 dark:bg-dark-3" />
|
<div className="size-10 rounded-lg bg-gray-2 dark:bg-dark-3" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div className="h-3 w-3/4 rounded bg-gray-2 dark:bg-dark-3" />
|
<div className="h-3 w-3/4 rounded bg-gray-2 dark:bg-dark-3" />
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import gsap from "gsap";
|
||||||
|
import { useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { AnimatedCounter } from "./animated-counter";
|
||||||
|
import { PulseIcon, TrendUpIcon } from "./icons";
|
||||||
|
|
||||||
|
export type DailyPoint = { date: string; label: string; count: number };
|
||||||
|
|
||||||
|
export type DailyChartAccent = {
|
||||||
|
/** Three stops for the line gradient (left → middle → right). */
|
||||||
|
line: [string, string, string];
|
||||||
|
/** Solid colour used for the area fill, dots and pulse. */
|
||||||
|
fill: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
data: DailyPoint[];
|
||||||
|
isLoading: boolean;
|
||||||
|
accent?: DailyChartAccent;
|
||||||
|
unit?: { singular: string; plural: string };
|
||||||
|
loadingLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const W = 1000;
|
||||||
|
const H = 240;
|
||||||
|
const PAD_L = 0;
|
||||||
|
const PAD_R = 0;
|
||||||
|
const PAD_T = 24;
|
||||||
|
const PAD_B = 36;
|
||||||
|
|
||||||
|
const DEFAULT_ACCENT: DailyChartAccent = {
|
||||||
|
line: ["#5750F1", "#8155FF", "#0ABEF9"],
|
||||||
|
fill: "#5750F1",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DailyTrendChart({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
accent = DEFAULT_ACCENT,
|
||||||
|
unit = { singular: "item", plural: "items" },
|
||||||
|
loadingLabel = "Loading…",
|
||||||
|
}: Props) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
const pathRef = useRef<SVGPathElement>(null);
|
||||||
|
const areaRef = useRef<SVGPathElement>(null);
|
||||||
|
const dotsRef = useRef<SVGGElement>(null);
|
||||||
|
|
||||||
|
// Unique gradient/filter ids so multiple charts can live on one page.
|
||||||
|
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
|
||||||
|
const fillId = `dtFill-${uid}`;
|
||||||
|
const lineId = `dtLine-${uid}`;
|
||||||
|
const glowId = `dtGlow-${uid}`;
|
||||||
|
|
||||||
|
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { linePath, areaPath, points, sumY } = useMemo(() => {
|
||||||
|
const max = Math.max(1, ...data.map((d) => d.count));
|
||||||
|
const innerW = W - PAD_L - PAD_R;
|
||||||
|
const innerH = H - PAD_T - PAD_B;
|
||||||
|
const step = data.length > 1 ? innerW / (data.length - 1) : innerW;
|
||||||
|
|
||||||
|
const pts = data.map((d, i) => ({
|
||||||
|
x: PAD_L + i * step,
|
||||||
|
y: PAD_T + innerH - (d.count / max) * innerH,
|
||||||
|
d,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (pts.length === 0) {
|
||||||
|
return { linePath: "", areaPath: "", points: pts, sumY: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = pts
|
||||||
|
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const area = `${line} L ${pts[pts.length - 1].x.toFixed(2)} ${PAD_T + innerH} L ${pts[0].x.toFixed(2)} ${PAD_T + innerH} Z`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
linePath: line,
|
||||||
|
areaPath: area,
|
||||||
|
points: pts,
|
||||||
|
sumY: data.reduce((acc, p) => acc + p.count, 0),
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const reduced =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
if (reduced) return;
|
||||||
|
gsap.from(rootRef.current, {
|
||||||
|
y: 24,
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.6,
|
||||||
|
ease: "power3.out",
|
||||||
|
delay: 0.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pathRef.current && linePath) {
|
||||||
|
const length = pathRef.current.getTotalLength();
|
||||||
|
gsap.fromTo(
|
||||||
|
pathRef.current,
|
||||||
|
{ strokeDasharray: length, strokeDashoffset: length },
|
||||||
|
{
|
||||||
|
strokeDashoffset: 0,
|
||||||
|
duration: 1.4,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
delay: 0.3,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (areaRef.current) {
|
||||||
|
gsap.fromTo(
|
||||||
|
areaRef.current,
|
||||||
|
{ opacity: 0, y: 12 },
|
||||||
|
{ opacity: 1, y: 0, duration: 1, delay: 0.5, ease: "power2.out" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dotsRef.current) {
|
||||||
|
gsap.fromTo(
|
||||||
|
dotsRef.current.querySelectorAll("circle"),
|
||||||
|
{ scale: 0, transformOrigin: "center" },
|
||||||
|
{
|
||||||
|
scale: 1,
|
||||||
|
duration: 0.4,
|
||||||
|
stagger: 0.05,
|
||||||
|
ease: "back.out(2)",
|
||||||
|
delay: 0.8,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, rootRef);
|
||||||
|
return () => ctx.revert();
|
||||||
|
}, [linePath]);
|
||||||
|
|
||||||
|
const last = points[points.length - 1]?.d.count ?? 0;
|
||||||
|
const prev = points[points.length - 2]?.d.count ?? 0;
|
||||||
|
const dayDelta = last - prev;
|
||||||
|
const avg = data.length ? Math.round(sumY / data.length) : 0;
|
||||||
|
|
||||||
|
const handleMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||||
|
if (!svgRef.current || !points.length) return;
|
||||||
|
const rect = svgRef.current.getBoundingClientRect();
|
||||||
|
const px = ((e.clientX - rect.left) / rect.width) * W;
|
||||||
|
let bestI = 0;
|
||||||
|
let bestD = Infinity;
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const d = Math.abs(points[i].x - px);
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
bestI = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setHoverIdx(bestI);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hovered = hoverIdx !== null ? points[hoverIdx] : null;
|
||||||
|
const innerH = H - PAD_T - PAD_B;
|
||||||
|
const tooltipLeftPct = hovered ? (hovered.x / W) * 100 : 0;
|
||||||
|
const tooltipTopPct = hovered ? (hovered.y / H) * 100 : 0;
|
||||||
|
const flipLeft = hovered ? hovered.x > W * 0.7 : false;
|
||||||
|
const flipRight = hovered ? hovered.x < W * 0.12 : false;
|
||||||
|
const flipDown = hovered ? hovered.y < H * 0.35 : false;
|
||||||
|
const tooltipTX = flipLeft ? "-100%" : flipRight ? "0%" : "-50%";
|
||||||
|
const tooltipTY = flipDown ? "calc(0% + 14px)" : "calc(-100% - 14px)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className="relative overflow-hidden rounded-2xl bg-white p-5 shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card md:p-6"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-dark dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-dark-5 dark:text-dark-6">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-xl border border-stroke px-3 py-1.5 dark:border-stroke-dark">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-dark-6">
|
||||||
|
Today
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-bold text-dark dark:text-white">
|
||||||
|
<AnimatedCounter value={last} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-stroke px-3 py-1.5 dark:border-stroke-dark">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-dark-6">
|
||||||
|
Avg/day
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-bold text-dark dark:text-white">
|
||||||
|
<AnimatedCounter value={avg} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="hidden items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium sm:inline-flex"
|
||||||
|
style={{ backgroundColor: `${accent.fill}1a`, color: accent.fill }}
|
||||||
|
>
|
||||||
|
<TrendUpIcon className="size-3.5" />
|
||||||
|
{dayDelta >= 0 ? "+" : ""}
|
||||||
|
{dayDelta} vs yesterday
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 w-full overflow-x-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-[240px] items-center justify-center text-sm text-dark-6">
|
||||||
|
<PulseIcon className="mr-2 size-4 animate-pulse" /> {loadingLabel}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative h-[240px] min-w-[600px]">
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
className="h-full w-full"
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
onMouseMove={handleMove}
|
||||||
|
onMouseLeave={() => setHoverIdx(null)}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={fillId} x1="0" x2="0" y1="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={accent.fill} stopOpacity="0.5" />
|
||||||
|
<stop offset="100%" stopColor={accent.fill} stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id={lineId} x1="0" x2="1" y1="0" y2="0">
|
||||||
|
<stop offset="0%" stopColor={accent.line[0]} />
|
||||||
|
<stop offset="50%" stopColor={accent.line[1]} />
|
||||||
|
<stop offset="100%" stopColor={accent.line[2]} />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id={glowId} x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{[0.25, 0.5, 0.75].map((p) => (
|
||||||
|
<line
|
||||||
|
key={p}
|
||||||
|
x1={PAD_L}
|
||||||
|
x2={W - PAD_R}
|
||||||
|
y1={PAD_T + innerH * p}
|
||||||
|
y2={PAD_T + innerH * p}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="text-stroke dark:text-stroke-dark"
|
||||||
|
strokeDasharray="4 6"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{areaPath && (
|
||||||
|
<path ref={areaRef} d={areaPath} fill={`url(#${fillId})`} />
|
||||||
|
)}
|
||||||
|
{linePath && (
|
||||||
|
<>
|
||||||
|
<path
|
||||||
|
d={linePath}
|
||||||
|
fill="none"
|
||||||
|
stroke={`url(#${lineId})`}
|
||||||
|
strokeWidth="6"
|
||||||
|
strokeOpacity="0.35"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
filter={`url(#${glowId})`}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
ref={pathRef}
|
||||||
|
d={linePath}
|
||||||
|
fill="none"
|
||||||
|
stroke={`url(#${lineId})`}
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hovered && (
|
||||||
|
<line
|
||||||
|
x1={hovered.x}
|
||||||
|
x2={hovered.x}
|
||||||
|
y1={PAD_T}
|
||||||
|
y2={PAD_T + innerH}
|
||||||
|
stroke={accent.fill}
|
||||||
|
strokeOpacity="0.35"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeDasharray="3 4"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<g ref={dotsRef}>
|
||||||
|
{points.map((p, i) => {
|
||||||
|
const isHover = hoverIdx === i;
|
||||||
|
const isLatest = i === points.length - 1;
|
||||||
|
return (
|
||||||
|
<g key={i}>
|
||||||
|
{isLatest && (
|
||||||
|
<>
|
||||||
|
<circle
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r="11"
|
||||||
|
fill={accent.fill}
|
||||||
|
opacity="0.25"
|
||||||
|
>
|
||||||
|
<animate
|
||||||
|
attributeName="r"
|
||||||
|
values="6;18;6"
|
||||||
|
dur="2.4s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="opacity"
|
||||||
|
values="0.35;0;0.35"
|
||||||
|
dur="2.4s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
<circle
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r="7"
|
||||||
|
fill={accent.fill}
|
||||||
|
opacity="0.35"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<circle
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r={isHover ? 7 : isLatest ? 5 : 4}
|
||||||
|
fill={isHover || isLatest ? accent.fill : "white"}
|
||||||
|
stroke={accent.fill}
|
||||||
|
strokeWidth={isHover ? 3 : 2}
|
||||||
|
className="transition-[r] duration-150 drop-shadow-sm"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{points.map((p, i) => {
|
||||||
|
const isFirst = i === 0;
|
||||||
|
const isLast = i === points.length - 1;
|
||||||
|
if (!(isFirst || isLast || i % 2 === 0)) return null;
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
key={i}
|
||||||
|
x={p.x}
|
||||||
|
y={H - 12}
|
||||||
|
textAnchor={isFirst ? "start" : isLast ? "end" : "middle"}
|
||||||
|
className="fill-dark-6 text-[10px]"
|
||||||
|
>
|
||||||
|
{p.d.label}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{hovered && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute z-10 rounded-xl border border-stroke bg-white px-3 py-2 text-xs shadow-lg dark:border-stroke-dark dark:bg-gray-dark"
|
||||||
|
style={{
|
||||||
|
left: `${tooltipLeftPct}%`,
|
||||||
|
top: `${tooltipTopPct}%`,
|
||||||
|
transform: `translate(${tooltipTX}, ${tooltipTY})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="font-semibold uppercase tracking-wider text-dark-6">
|
||||||
|
{hovered.d.label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-baseline gap-1.5">
|
||||||
|
<span className="text-lg font-bold text-dark dark:text-white tabular-nums">
|
||||||
|
{hovered.d.count}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-dark-6">
|
||||||
|
{hovered.d.count === 1 ? unit.singular : unit.plural}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{hoverIdx !== null && hoverIdx > 0 && (
|
||||||
|
<div className="mt-1 text-[10px] text-dark-6">
|
||||||
|
{(() => {
|
||||||
|
const diff =
|
||||||
|
hovered.d.count - points[hoverIdx - 1].d.count;
|
||||||
|
const sign = diff > 0 ? "+" : "";
|
||||||
|
return `${sign}${diff} vs previous day`;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ import { ClosingSoonCard } from "./closing-soon";
|
|||||||
import { CountryDistribution } from "./country-distribution";
|
import { CountryDistribution } from "./country-distribution";
|
||||||
import { DashboardHero } from "./hero";
|
import { DashboardHero } from "./hero";
|
||||||
import { NoticeTypeBreakdown } from "./notice-types";
|
import { NoticeTypeBreakdown } from "./notice-types";
|
||||||
|
import { ScrapePortals } from "./portals";
|
||||||
import { RecentTenders } from "./recent-tenders";
|
import { RecentTenders } from "./recent-tenders";
|
||||||
|
import { StatisticsSection } from "./statistics-section";
|
||||||
import { TenderStatCards } from "./stat-cards";
|
import { TenderStatCards } from "./stat-cards";
|
||||||
import { TenderTrendsChart } from "./trends-chart";
|
import { TenderTrendsChart } from "./trends-chart";
|
||||||
import { useDashboardData } from "./use-dashboard-data";
|
import { useDashboardData } from "./use-dashboard-data";
|
||||||
@@ -18,11 +20,11 @@ export function TenderDashboard() {
|
|||||||
totalValue,
|
totalValue,
|
||||||
valueCurrency,
|
valueCurrency,
|
||||||
countries,
|
countries,
|
||||||
noticeTypes,
|
|
||||||
trend,
|
trend,
|
||||||
recent,
|
recent,
|
||||||
recentIsLoading,
|
recentIsLoading,
|
||||||
closingSoonList,
|
portals,
|
||||||
|
portalsIsLoading,
|
||||||
} = useDashboardData();
|
} = useDashboardData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -52,20 +54,25 @@ export function TenderDashboard() {
|
|||||||
<CountryDistribution countries={countries} isLoading={isPending} />
|
<CountryDistribution countries={countries} isLoading={isPending} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-12 xl:col-span-5">
|
{/* <div className="col-span-12 xl:col-span-5">
|
||||||
<NoticeTypeBreakdown
|
<NoticeTypeBreakdown
|
||||||
noticeTypes={noticeTypes}
|
noticeTypes={noticeTypes}
|
||||||
isLoading={isPending}
|
isLoading={isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div> */}
|
||||||
<div className="col-span-12 xl:col-span-7">
|
{/* <div className="col-span-12 xl:col-span-7">
|
||||||
<ClosingSoonCard tenders={closingSoonList} isLoading={isPending} />
|
<ClosingSoonCard tenders={closingSoonList} isLoading={isPending} />
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
<div className="col-span-12">
|
<div className="col-span-12 xl:col-span-8">
|
||||||
<RecentTenders tenders={recent} isLoading={recentIsLoading} />
|
<RecentTenders tenders={recent} isLoading={recentIsLoading} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-span-12 xl:col-span-4">
|
||||||
|
<ScrapePortals portals={portals} isLoading={portalsIsLoading} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<StatisticsSection />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { TPortal } from "@/lib/api";
|
||||||
|
import { truncateString } from "@/utils/shared";
|
||||||
|
import gsap from "gsap";
|
||||||
|
import { useLayoutEffect, useRef } from "react";
|
||||||
|
import { GlobeIcon } from "./icons";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
portals: TPortal[];
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ScrapePortals({ portals, isLoading }: Props) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const root = rootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const reduced =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
if (reduced) return;
|
||||||
|
gsap.from(root, {
|
||||||
|
opacity: 0,
|
||||||
|
y: 24,
|
||||||
|
duration: 0.6,
|
||||||
|
ease: "power3.out",
|
||||||
|
delay: 0.35,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = root.querySelectorAll("[data-portal-row]");
|
||||||
|
if (rows.length > 0) {
|
||||||
|
gsap.from(rows, {
|
||||||
|
x: 24,
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.5,
|
||||||
|
stagger: 0.06,
|
||||||
|
delay: 0.55,
|
||||||
|
ease: "power2.out",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, root);
|
||||||
|
return () => ctx.revert();
|
||||||
|
}, [portals.length, isLoading]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:shadow-card dark:ring-stroke-dark"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-stroke px-5 py-4 dark:border-stroke-dark">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<GlobeIcon className="size-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-dark dark:text-white">
|
||||||
|
Scrape Portals
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-dark-6">
|
||||||
|
Tender sources monitored by the pipeline
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isLoading && portals.length > 0 && (
|
||||||
|
<span className="rounded-full bg-gray-2 px-2.5 py-1 text-xs font-semibold tabular-nums text-dark-5 dark:bg-dark-3 dark:text-dark-6">
|
||||||
|
{portals.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="max-h-[420px] flex-1 divide-y divide-stroke overflow-y-auto dark:divide-stroke-dark">
|
||||||
|
{isLoading
|
||||||
|
? Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex animate-pulse items-center gap-3 px-5 py-3.5"
|
||||||
|
>
|
||||||
|
<div className="size-8 rounded-lg bg-gray-2 dark:bg-dark-3" />
|
||||||
|
<div className="h-3 flex-1 rounded bg-gray-2 dark:bg-dark-3" />
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
: portals.map((portal, index) => (
|
||||||
|
<li key={`${portal}-${index}`} data-portal-row>
|
||||||
|
<p
|
||||||
|
|
||||||
|
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="group flex items-center gap-3 px-5 py-3.5 transition-colors hover:bg-gray-2 dark:hover:bg-dark-2"
|
||||||
|
>
|
||||||
|
<span className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-bold text-primary">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 text-sm font-medium text-dark transition-colors group-hover:text-primary dark:text-white">
|
||||||
|
{portal.length > 60
|
||||||
|
? truncateString(portal, 28, 28)
|
||||||
|
: portal}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{!isLoading && portals.length === 0 && (
|
||||||
|
<li className="px-5 py-10 text-center text-sm text-dark-6">
|
||||||
|
No portals configured.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,19 +45,25 @@ export function RecentTenders({ tenders, isLoading }: Props) {
|
|||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
const root = rootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
const reduced =
|
const reduced =
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
const ctx = gsap.context(() => {
|
const ctx = gsap.context(() => {
|
||||||
if (reduced) return;
|
if (reduced) return;
|
||||||
gsap.from(rootRef.current, {
|
gsap.from(root, {
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
y: 24,
|
y: 24,
|
||||||
duration: 0.6,
|
duration: 0.6,
|
||||||
ease: "power3.out",
|
ease: "power3.out",
|
||||||
delay: 0.35,
|
delay: 0.35,
|
||||||
});
|
});
|
||||||
gsap.from("[data-recent-row]", {
|
|
||||||
|
const rows = root.querySelectorAll("[data-recent-row]");
|
||||||
|
if (rows.length > 0) {
|
||||||
|
gsap.from(rows, {
|
||||||
x: -24,
|
x: -24,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
duration: 0.5,
|
duration: 0.5,
|
||||||
@@ -65,14 +71,15 @@ export function RecentTenders({ tenders, isLoading }: Props) {
|
|||||||
delay: 0.55,
|
delay: 0.55,
|
||||||
ease: "power2.out",
|
ease: "power2.out",
|
||||||
});
|
});
|
||||||
}, rootRef);
|
}
|
||||||
|
}, root);
|
||||||
return () => ctx.revert();
|
return () => ctx.revert();
|
||||||
}, [tenders.length, isLoading]);
|
}, [tenders.length, isLoading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card"
|
className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:shadow-card dark:ring-stroke-dark"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-stroke px-5 py-4 dark:border-stroke-dark">
|
<div className="flex items-center justify-between border-b border-stroke px-5 py-4 dark:border-stroke-dark">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { AnimatedCounter } from "./animated-counter";
|
||||||
|
import { DailyChartAccent, DailyTrendChart } from "./daily-trend-chart";
|
||||||
|
import { SparkleIcon, TenderIcon } from "./icons";
|
||||||
|
import {
|
||||||
|
STATISTICS_DAY_OPTIONS,
|
||||||
|
StatisticsDays,
|
||||||
|
useStatistics,
|
||||||
|
} from "./use-statistics";
|
||||||
|
|
||||||
|
const TED_ACCENT: DailyChartAccent = {
|
||||||
|
line: ["#5750F1", "#8155FF", "#0ABEF9"],
|
||||||
|
fill: "#5750F1",
|
||||||
|
};
|
||||||
|
const DOCS_ACCENT: DailyChartAccent = {
|
||||||
|
line: ["#0ABEF9", "#3C9DF8", "#22AD5C"],
|
||||||
|
fill: "#0ABEF9",
|
||||||
|
};
|
||||||
|
const TRANSLATE_ACCENT: DailyChartAccent = {
|
||||||
|
line: ["#F59460", "#F97316", "#EF4444"],
|
||||||
|
fill: "#F59460",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatisticsSection() {
|
||||||
|
const {
|
||||||
|
days,
|
||||||
|
setDays,
|
||||||
|
isLoading,
|
||||||
|
scrapedTed,
|
||||||
|
scrapedDocuments,
|
||||||
|
translatedNotices,
|
||||||
|
totalScrapedDocuments,
|
||||||
|
totalTranslatedTenders,
|
||||||
|
} = useStatistics();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4 md:space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-dark dark:text-white">
|
||||||
|
Scraping & Translation
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-dark-5 dark:text-dark-6">
|
||||||
|
Daily ingestion and translation activity
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DaysSelector value={days} onChange={setDays} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<TotalCard
|
||||||
|
label="Total Scraped Documents"
|
||||||
|
hint="All documents across every tender"
|
||||||
|
value={totalScrapedDocuments}
|
||||||
|
Icon={TenderIcon}
|
||||||
|
color="#0ABEF9"
|
||||||
|
accentGradient="from-[#0ABEF9] to-[#3C50E0]"
|
||||||
|
loading={isLoading}
|
||||||
|
/>
|
||||||
|
<TotalCard
|
||||||
|
label="Total Translated Tenders"
|
||||||
|
hint="Lifetime successful AI translations"
|
||||||
|
value={totalTranslatedTenders}
|
||||||
|
Icon={SparkleIcon}
|
||||||
|
color="#F59460"
|
||||||
|
accentGradient="from-orange-500 to-amber-400"
|
||||||
|
loading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 md:space-y-6">
|
||||||
|
<DailyTrendChart
|
||||||
|
title="Scraped TED per day"
|
||||||
|
subtitle="TED notices imported into the system"
|
||||||
|
data={scrapedTed}
|
||||||
|
isLoading={isLoading}
|
||||||
|
accent={TED_ACCENT}
|
||||||
|
unit={{ singular: "notice", plural: "notices" }}
|
||||||
|
loadingLabel="Loading TED activity…"
|
||||||
|
/>
|
||||||
|
<DailyTrendChart
|
||||||
|
title="Scraped documents per day"
|
||||||
|
subtitle="Individual tender documents scraped"
|
||||||
|
data={scrapedDocuments}
|
||||||
|
isLoading={isLoading}
|
||||||
|
accent={DOCS_ACCENT}
|
||||||
|
unit={{ singular: "document", plural: "documents" }}
|
||||||
|
loadingLabel="Loading documents…"
|
||||||
|
/>
|
||||||
|
<DailyTrendChart
|
||||||
|
title="Translated notices per day"
|
||||||
|
subtitle="Successful AI translation requests"
|
||||||
|
data={translatedNotices}
|
||||||
|
isLoading={isLoading}
|
||||||
|
accent={TRANSLATE_ACCENT}
|
||||||
|
unit={{ singular: "notice", plural: "notices" }}
|
||||||
|
loadingLabel="Loading translations…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DaysSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: StatisticsDays;
|
||||||
|
onChange: (days: StatisticsDays) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-1 rounded-xl border border-stroke bg-white p-1 dark:border-stroke-dark dark:bg-gray-dark">
|
||||||
|
{STATISTICS_DAY_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(option)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
|
||||||
|
value === option
|
||||||
|
? "bg-primary text-white"
|
||||||
|
: "text-dark-5 hover:bg-gray-2 dark:text-dark-6 dark:hover:bg-dark-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option}d
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TotalCard({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
value,
|
||||||
|
Icon,
|
||||||
|
color,
|
||||||
|
accentGradient,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
value: number;
|
||||||
|
Icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||||
|
color: string;
|
||||||
|
accentGradient: string;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative isolate overflow-hidden rounded-2xl bg-white p-5 shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card">
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute -right-12 -top-12 z-0 size-32 rounded-full bg-gradient-to-br opacity-25 blur-2xl",
|
||||||
|
accentGradient,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex size-12 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br text-white shadow-lg",
|
||||||
|
accentGradient,
|
||||||
|
)}
|
||||||
|
style={{ boxShadow: `0 10px 30px -10px ${color}88` }}
|
||||||
|
>
|
||||||
|
<Icon className="size-6" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[30px] font-bold leading-none" dir="ltr" lang="en">
|
||||||
|
{loading ? (
|
||||||
|
<span className="inline-block h-8 w-24 animate-pulse rounded bg-gray-2 dark:bg-dark-3" />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block bg-gradient-to-br bg-clip-text text-transparent",
|
||||||
|
accentGradient,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AnimatedCounter value={value} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm font-semibold text-dark dark:text-white">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-dark-6">{hint}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { TDashboardTrendPoint } from "@/lib/api/types";
|
||||||
|
import type { DailyPoint } from "./daily-trend-chart";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an API daily series (`{ date: "YYYY-MM-DD", count }`) into chart
|
||||||
|
* points with a locale-formatted axis label. API dates are UTC calendar days
|
||||||
|
* and are kept verbatim in `date` for lookups.
|
||||||
|
*/
|
||||||
|
export function toLabeledPoints(series: TDashboardTrendPoint[]): DailyPoint[] {
|
||||||
|
return series.map((point) => {
|
||||||
|
const [y, m, d] = point.date.split("-").map(Number);
|
||||||
|
const date = new Date(Date.UTC(y, (m ?? 1) - 1, d ?? 1));
|
||||||
|
return {
|
||||||
|
date: point.date,
|
||||||
|
label: date.toLocaleDateString("en", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
}),
|
||||||
|
count: point.count,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,382 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import gsap from "gsap";
|
import { DailyPoint, DailyTrendChart } from "./daily-trend-chart";
|
||||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { AnimatedCounter } from "./animated-counter";
|
|
||||||
import { PulseIcon, TrendUpIcon } from "./icons";
|
|
||||||
|
|
||||||
type Point = { date: string; label: string; count: number };
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: Point[];
|
data: DailyPoint[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const W = 1000;
|
|
||||||
const H = 240;
|
|
||||||
const PAD_L = 0;
|
|
||||||
const PAD_R = 0;
|
|
||||||
const PAD_T = 24;
|
|
||||||
const PAD_B = 36;
|
|
||||||
|
|
||||||
export function TenderTrendsChart({ data, isLoading }: Props) {
|
export function TenderTrendsChart({ data, isLoading }: Props) {
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
|
||||||
const pathRef = useRef<SVGPathElement>(null);
|
|
||||||
const areaRef = useRef<SVGPathElement>(null);
|
|
||||||
const dotsRef = useRef<SVGGElement>(null);
|
|
||||||
|
|
||||||
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const { linePath, areaPath, points, sumY } = useMemo(() => {
|
|
||||||
const max = Math.max(1, ...data.map((d) => d.count));
|
|
||||||
const innerW = W - PAD_L - PAD_R;
|
|
||||||
const innerH = H - PAD_T - PAD_B;
|
|
||||||
const step = data.length > 1 ? innerW / (data.length - 1) : innerW;
|
|
||||||
|
|
||||||
const pts = data.map((d, i) => ({
|
|
||||||
x: PAD_L + i * step,
|
|
||||||
y: PAD_T + innerH - (d.count / max) * innerH,
|
|
||||||
d,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (pts.length === 0) {
|
|
||||||
return { linePath: "", areaPath: "", points: pts, sumY: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const line = pts
|
|
||||||
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
const area = `${line} L ${pts[pts.length - 1].x.toFixed(2)} ${PAD_T + innerH} L ${pts[0].x.toFixed(2)} ${PAD_T + innerH} Z`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
linePath: line,
|
|
||||||
areaPath: area,
|
|
||||||
points: pts,
|
|
||||||
sumY: data.reduce((acc, p) => acc + p.count, 0),
|
|
||||||
};
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const reduced =
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
||||||
|
|
||||||
const ctx = gsap.context(() => {
|
|
||||||
if (reduced) return;
|
|
||||||
gsap.from(rootRef.current, {
|
|
||||||
y: 24,
|
|
||||||
opacity: 0,
|
|
||||||
duration: 0.6,
|
|
||||||
ease: "power3.out",
|
|
||||||
delay: 0.2,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pathRef.current && linePath) {
|
|
||||||
const length = pathRef.current.getTotalLength();
|
|
||||||
gsap.fromTo(
|
|
||||||
pathRef.current,
|
|
||||||
{ strokeDasharray: length, strokeDashoffset: length },
|
|
||||||
{
|
|
||||||
strokeDashoffset: 0,
|
|
||||||
duration: 1.4,
|
|
||||||
ease: "power2.inOut",
|
|
||||||
delay: 0.3,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (areaRef.current) {
|
|
||||||
gsap.fromTo(
|
|
||||||
areaRef.current,
|
|
||||||
{ opacity: 0, y: 12 },
|
|
||||||
{ opacity: 1, y: 0, duration: 1, delay: 0.5, ease: "power2.out" },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (dotsRef.current) {
|
|
||||||
gsap.fromTo(
|
|
||||||
dotsRef.current.querySelectorAll("circle"),
|
|
||||||
{ scale: 0, transformOrigin: "center" },
|
|
||||||
{
|
|
||||||
scale: 1,
|
|
||||||
duration: 0.4,
|
|
||||||
stagger: 0.05,
|
|
||||||
ease: "back.out(2)",
|
|
||||||
delay: 0.8,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, rootRef);
|
|
||||||
return () => ctx.revert();
|
|
||||||
}, [linePath]);
|
|
||||||
|
|
||||||
const last = points[points.length - 1]?.d.count ?? 0;
|
|
||||||
const prev = points[points.length - 2]?.d.count ?? 0;
|
|
||||||
const dayDelta = last - prev;
|
|
||||||
const avg = data.length ? Math.round(sumY / data.length) : 0;
|
|
||||||
|
|
||||||
const handleMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
|
||||||
if (!svgRef.current || !points.length) return;
|
|
||||||
const rect = svgRef.current.getBoundingClientRect();
|
|
||||||
const px = ((e.clientX - rect.left) / rect.width) * W;
|
|
||||||
let bestI = 0;
|
|
||||||
let bestD = Infinity;
|
|
||||||
for (let i = 0; i < points.length; i++) {
|
|
||||||
const d = Math.abs(points[i].x - px);
|
|
||||||
if (d < bestD) {
|
|
||||||
bestD = d;
|
|
||||||
bestI = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setHoverIdx(bestI);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hovered = hoverIdx !== null ? points[hoverIdx] : null;
|
|
||||||
const innerH = H - PAD_T - PAD_B;
|
|
||||||
const tooltipLeftPct = hovered ? (hovered.x / W) * 100 : 0;
|
|
||||||
const tooltipTopPct = hovered ? (hovered.y / H) * 100 : 0;
|
|
||||||
const flipLeft = hovered ? hovered.x > W * 0.7 : false;
|
|
||||||
const flipRight = hovered ? hovered.x < W * 0.12 : false;
|
|
||||||
const flipDown = hovered ? hovered.y < H * 0.35 : false;
|
|
||||||
const tooltipTX = flipLeft ? "-100%" : flipRight ? "0%" : "-50%";
|
|
||||||
const tooltipTY = flipDown ? "calc(0% + 14px)" : "calc(-100% - 14px)";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<DailyTrendChart
|
||||||
ref={rootRef}
|
title="Tender Flow — Last 14 days"
|
||||||
className="relative overflow-hidden rounded-2xl bg-white p-5 shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card md:p-6"
|
subtitle="New tender notices received per day"
|
||||||
>
|
data={data}
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
isLoading={isLoading}
|
||||||
<div>
|
unit={{ singular: "tender", plural: "tenders" }}
|
||||||
<h2 className="text-lg font-bold text-dark dark:text-white">
|
loadingLabel="Loading flow…"
|
||||||
Tender Flow — Last 14 days
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-sm text-dark-5 dark:text-dark-6">
|
|
||||||
New tender notices received per day
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="rounded-xl border border-stroke px-3 py-1.5 dark:border-stroke-dark">
|
|
||||||
<div className="text-[11px] uppercase tracking-wider text-dark-6">
|
|
||||||
Today
|
|
||||||
</div>
|
|
||||||
<div className="text-base font-bold text-dark dark:text-white">
|
|
||||||
<AnimatedCounter value={last} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border border-stroke px-3 py-1.5 dark:border-stroke-dark">
|
|
||||||
<div className="text-[11px] uppercase tracking-wider text-dark-6">
|
|
||||||
Avg/day
|
|
||||||
</div>
|
|
||||||
<div className="text-base font-bold text-dark dark:text-white">
|
|
||||||
<AnimatedCounter value={avg} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="hidden items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary sm:inline-flex">
|
|
||||||
<TrendUpIcon className="size-3.5" />
|
|
||||||
{dayDelta >= 0 ? "+" : ""}
|
|
||||||
{dayDelta} vs yesterday
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 w-full overflow-x-auto">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex h-[240px] items-center justify-center text-sm text-dark-6">
|
|
||||||
<PulseIcon className="mr-2 size-4 animate-pulse" /> Loading flow…
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="relative h-[240px] min-w-[600px]">
|
|
||||||
<svg
|
|
||||||
ref={svgRef}
|
|
||||||
viewBox={`0 0 ${W} ${H}`}
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
className="h-full w-full"
|
|
||||||
style={{ overflow: "visible" }}
|
|
||||||
onMouseMove={handleMove}
|
|
||||||
onMouseLeave={() => setHoverIdx(null)}
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="tenderTrendFill" x1="0" x2="0" y1="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#5750F1" stopOpacity="0.5" />
|
|
||||||
<stop offset="100%" stopColor="#5750F1" stopOpacity="0" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="tenderTrendLine" x1="0" x2="1" y1="0" y2="0">
|
|
||||||
<stop offset="0%" stopColor="#5750F1" />
|
|
||||||
<stop offset="50%" stopColor="#8155FF" />
|
|
||||||
<stop offset="100%" stopColor="#0ABEF9" />
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="tenderTrendGlow" x="-20%" y="-20%" width="140%" height="140%">
|
|
||||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
|
||||||
<feMerge>
|
|
||||||
<feMergeNode in="blur" />
|
|
||||||
<feMergeNode in="SourceGraphic" />
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
{[0.25, 0.5, 0.75].map((p) => (
|
|
||||||
<line
|
|
||||||
key={p}
|
|
||||||
x1={PAD_L}
|
|
||||||
x2={W - PAD_R}
|
|
||||||
y1={PAD_T + innerH * p}
|
|
||||||
y2={PAD_T + innerH * p}
|
|
||||||
stroke="currentColor"
|
|
||||||
className="text-stroke dark:text-stroke-dark"
|
|
||||||
strokeDasharray="4 6"
|
|
||||||
strokeWidth="1"
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
|
|
||||||
{areaPath && (
|
|
||||||
<path ref={areaRef} d={areaPath} fill="url(#tenderTrendFill)" />
|
|
||||||
)}
|
|
||||||
{linePath && (
|
|
||||||
<>
|
|
||||||
<path
|
|
||||||
d={linePath}
|
|
||||||
fill="none"
|
|
||||||
stroke="url(#tenderTrendLine)"
|
|
||||||
strokeWidth="6"
|
|
||||||
strokeOpacity="0.35"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
filter="url(#tenderTrendGlow)"
|
|
||||||
vectorEffect="non-scaling-stroke"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
ref={pathRef}
|
|
||||||
d={linePath}
|
|
||||||
fill="none"
|
|
||||||
stroke="url(#tenderTrendLine)"
|
|
||||||
strokeWidth="3"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
vectorEffect="non-scaling-stroke"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hovered && (
|
|
||||||
<line
|
|
||||||
x1={hovered.x}
|
|
||||||
x2={hovered.x}
|
|
||||||
y1={PAD_T}
|
|
||||||
y2={PAD_T + innerH}
|
|
||||||
stroke="#5750F1"
|
|
||||||
strokeOpacity="0.35"
|
|
||||||
strokeWidth="1"
|
|
||||||
strokeDasharray="3 4"
|
|
||||||
vectorEffect="non-scaling-stroke"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<g ref={dotsRef}>
|
|
||||||
{points.map((p, i) => {
|
|
||||||
const isHover = hoverIdx === i;
|
|
||||||
const isLatest = i === points.length - 1;
|
|
||||||
return (
|
|
||||||
<g key={i}>
|
|
||||||
{isLatest && (
|
|
||||||
<>
|
|
||||||
<circle
|
|
||||||
cx={p.x}
|
|
||||||
cy={p.y}
|
|
||||||
r="11"
|
|
||||||
fill="#5750F1"
|
|
||||||
opacity="0.25"
|
|
||||||
>
|
|
||||||
<animate
|
|
||||||
attributeName="r"
|
|
||||||
values="6;18;6"
|
|
||||||
dur="2.4s"
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
<animate
|
|
||||||
attributeName="opacity"
|
|
||||||
values="0.35;0;0.35"
|
|
||||||
dur="2.4s"
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
</circle>
|
|
||||||
<circle
|
|
||||||
cx={p.x}
|
|
||||||
cy={p.y}
|
|
||||||
r="7"
|
|
||||||
fill="#5750F1"
|
|
||||||
opacity="0.35"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<circle
|
|
||||||
cx={p.x}
|
|
||||||
cy={p.y}
|
|
||||||
r={isHover ? 7 : isLatest ? 5 : 4}
|
|
||||||
fill={isHover || isLatest ? "#5750F1" : "white"}
|
|
||||||
stroke="#5750F1"
|
|
||||||
strokeWidth={isHover ? 3 : 2}
|
|
||||||
className="transition-[r] duration-150 drop-shadow-sm"
|
|
||||||
vectorEffect="non-scaling-stroke"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</g>
|
|
||||||
|
|
||||||
{points.map((p, i) => {
|
|
||||||
const isFirst = i === 0;
|
|
||||||
const isLast = i === points.length - 1;
|
|
||||||
if (!(isFirst || isLast || i % 2 === 0)) return null;
|
|
||||||
return (
|
|
||||||
<text
|
|
||||||
key={i}
|
|
||||||
x={p.x}
|
|
||||||
y={H - 12}
|
|
||||||
textAnchor={isFirst ? "start" : isLast ? "end" : "middle"}
|
|
||||||
className="fill-dark-6 text-[10px]"
|
|
||||||
>
|
|
||||||
{p.d.label}
|
|
||||||
</text>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{hovered && (
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute z-10 rounded-xl border border-stroke bg-white px-3 py-2 text-xs shadow-lg dark:border-stroke-dark dark:bg-gray-dark"
|
|
||||||
style={{
|
|
||||||
left: `${tooltipLeftPct}%`,
|
|
||||||
top: `${tooltipTopPct}%`,
|
|
||||||
transform: `translate(${tooltipTX}, ${tooltipTY})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="font-semibold uppercase tracking-wider text-dark-6">
|
|
||||||
{hovered.d.label}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex items-baseline gap-1.5">
|
|
||||||
<span className="text-lg font-bold text-dark dark:text-white tabular-nums">
|
|
||||||
{hovered.d.count}
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] text-dark-6">
|
|
||||||
{hovered.d.count === 1 ? "tender" : "tenders"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{hoverIdx !== null && hoverIdx > 0 && (
|
|
||||||
<div className="mt-1 text-[10px] text-dark-6">
|
|
||||||
{(() => {
|
|
||||||
const diff =
|
|
||||||
hovered.d.count - points[hoverIdx - 1].d.count;
|
|
||||||
const sign = diff > 0 ? "+" : "";
|
|
||||||
return `${sign}${diff} vs previous day`;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,16 +8,22 @@ import {
|
|||||||
useDashboardTrendQuery,
|
useDashboardTrendQuery,
|
||||||
useTendersQuery,
|
useTendersQuery,
|
||||||
} from "@/hooks/queries";
|
} from "@/hooks/queries";
|
||||||
|
import { useGetPortalsQuery } from "@/hooks/queries/usePortalQueries";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { toLabeledPoints } from "./to-labeled-points";
|
||||||
|
|
||||||
export type DashboardMetrics = ReturnType<typeof useDashboardData>;
|
export type DashboardMetrics = ReturnType<typeof useDashboardData>;
|
||||||
|
|
||||||
export function useDashboardData() {
|
export function useDashboardData() {
|
||||||
const summaryQuery = useDashboardSummaryQuery();
|
const summaryQuery = useDashboardSummaryQuery({
|
||||||
|
closing_window: 168,
|
||||||
|
days: 14,
|
||||||
|
});
|
||||||
const trendQuery = useDashboardTrendQuery({ days: 14, metric: "created" });
|
const trendQuery = useDashboardTrendQuery({ days: 14, metric: "created" });
|
||||||
const countriesQuery = useDashboardCountriesQuery({ limit: 6 });
|
const countriesQuery = useDashboardCountriesQuery({ limit: 6 });
|
||||||
const noticeTypesQuery = useDashboardNoticeTypesQuery();
|
const noticeTypesQuery = useDashboardNoticeTypesQuery();
|
||||||
const closingSoonQuery = useDashboardClosingSoonQuery({ limit: 5 });
|
const closingSoonQuery = useDashboardClosingSoonQuery({ limit: 5 });
|
||||||
|
const portalsQuery = useGetPortalsQuery();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: recentData,
|
data: recentData,
|
||||||
@@ -61,21 +67,10 @@ export function useDashboardData() {
|
|||||||
refetchRecent();
|
refetchRecent();
|
||||||
};
|
};
|
||||||
|
|
||||||
const trend = useMemo(() => {
|
const trend = useMemo(
|
||||||
const series = trendResponse?.series ?? [];
|
() => toLabeledPoints(trendResponse?.series ?? []),
|
||||||
return series.map((point) => {
|
[trendResponse],
|
||||||
const [y, m, d] = point.date.split("-").map(Number);
|
);
|
||||||
const date = new Date(Date.UTC(y, (m ?? 1) - 1, d ?? 1));
|
|
||||||
return {
|
|
||||||
date: point.date,
|
|
||||||
label: date.toLocaleDateString("en", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
}),
|
|
||||||
count: point.count,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [trendResponse]);
|
|
||||||
|
|
||||||
const countries = useMemo(
|
const countries = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -97,6 +92,8 @@ export function useDashboardData() {
|
|||||||
|
|
||||||
const recent = recentData?.data?.tenders ?? [];
|
const recent = recentData?.data?.tenders ?? [];
|
||||||
const recentIsLoading = recentPending && recent.length === 0;
|
const recentIsLoading = recentPending && recent.length === 0;
|
||||||
|
const portals = portalsQuery.data?.data ?? [];
|
||||||
|
const portalsIsLoading = portalsQuery.isPending && portals.length === 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isPending,
|
isPending,
|
||||||
@@ -114,5 +111,7 @@ export function useDashboardData() {
|
|||||||
recent,
|
recent,
|
||||||
recentIsLoading,
|
recentIsLoading,
|
||||||
closingSoonList,
|
closingSoonList,
|
||||||
|
portals,
|
||||||
|
portalsIsLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useDashboardStatisticsQuery,
|
||||||
|
useDashboardSummaryQuery,
|
||||||
|
} from "@/hooks/queries";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { toLabeledPoints } from "./to-labeled-points";
|
||||||
|
|
||||||
|
export const STATISTICS_DAY_OPTIONS = [7, 14, 30, 90] as const;
|
||||||
|
|
||||||
|
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(summary?.scraped_ted ?? []),
|
||||||
|
[summary],
|
||||||
|
);
|
||||||
|
const scrapedDocuments = useMemo(
|
||||||
|
() => toLabeledPoints(daily?.scraped_documents ?? []),
|
||||||
|
[daily],
|
||||||
|
);
|
||||||
|
const translatedNotices = useMemo(
|
||||||
|
() => toLabeledPoints(daily?.translated_notices ?? []),
|
||||||
|
[daily],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
days,
|
||||||
|
setDays,
|
||||||
|
isLoading: query.isPending || summaryQuery.isPending,
|
||||||
|
isError: query.isError || summaryQuery.isError,
|
||||||
|
refetch: () => {
|
||||||
|
summaryQuery.refetch();
|
||||||
|
query.refetch();
|
||||||
|
},
|
||||||
|
scrapedTed,
|
||||||
|
scrapedDocuments,
|
||||||
|
translatedNotices,
|
||||||
|
totalScrapedDocuments: stats?.totals.scraped_documents ?? 0,
|
||||||
|
totalTranslatedTenders: stats?.totals.translated_tenders ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,39 +1,188 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import Loading from "@/components/loading";
|
|
||||||
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
|
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
|
||||||
import { NotificationDetailsCard } from "@/components/ui/notification-details-card";
|
import Loading from "@/components/loading";
|
||||||
import { useGetNotificationDetails } from "@/hooks/queries/useNotificationQueries";
|
import Status from "@/components/ui/Status";
|
||||||
import { BreadcrumbItem } from "@/types/shared";
|
import { BellIcon } from "@/components/Layouts/header/notification/icons";
|
||||||
import { use } from "react";
|
import Link from "next/link";
|
||||||
|
import { useNotificationDetailsPresenter } from "./useNotificationDetailsPresenter";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotificationDetailsPage = ({ params }: IProps) => {
|
const NotificationDetailsPage = ({ params }: IProps) => {
|
||||||
const { id } = use(params);
|
const { isLoading, rootRef, breadcrumbItems, meta, details } =
|
||||||
const { data, isLoading } = useGetNotificationDetails(id);
|
useNotificationDetailsPresenter({ params });
|
||||||
|
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
|
||||||
{
|
|
||||||
href: "/",
|
|
||||||
name: "Dashboard",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/notification-history",
|
|
||||||
name: "Notification history",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: `/notification-history/${id}`,
|
|
||||||
name: "Notification details",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
if (isLoading) return <Loading />;
|
if (isLoading) return <Loading />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div ref={rootRef} className="flex flex-col gap-6">
|
||||||
|
<div data-anim="breadcrumb">
|
||||||
<Breadcrumb items={breadcrumbItems} />
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
{data?.data && <NotificationDetailsCard data={data.data} />}
|
</div>
|
||||||
</>
|
|
||||||
|
{/* Hero header */}
|
||||||
|
<section
|
||||||
|
data-anim="hero"
|
||||||
|
className="relative isolate overflow-hidden rounded-3xl border border-stroke/60 bg-gradient-to-br from-primary via-primary to-blue-700 p-6 text-white shadow-1 md:p-9 dark:border-transparent dark:shadow-card"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-anim="aurora"
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -inset-[40%] z-0 opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"conic-gradient(from 0deg at 50% 50%, #5750F1 0deg, #0ABEF9 80deg, transparent 140deg, #8155FF 200deg, #5750F1 280deg, transparent 320deg, #5750F1 360deg)",
|
||||||
|
filter: "blur(64px)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-orb="a"
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -right-24 -top-32 z-0 h-72 w-72 rounded-full bg-white/20 blur-3xl"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-orb="b"
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -bottom-32 -left-16 z-0 h-64 w-64 rounded-full bg-[#0ABEF9]/40 blur-3xl"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-orb="c"
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute left-1/2 top-1/3 z-0 h-52 w-52 -translate-x-1/2 rounded-full bg-[#8155FF]/30 blur-3xl"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 z-0 rounded-3xl ring-1 ring-inset ring-white/10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col gap-6 md:flex-row md:items-start">
|
||||||
|
<span
|
||||||
|
data-anim="hero-icon"
|
||||||
|
className="grid size-16 shrink-0 place-items-center rounded-2xl bg-white/20 text-white shadow-theme-sm ring-1 ring-white/30 backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<BellIcon />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h1
|
||||||
|
className="relative z-[1] text-2xl font-bold leading-tight md:text-3xl"
|
||||||
|
aria-label={meta?.title}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-title-inner
|
||||||
|
className="relative z-[1] inline-block will-change-transform"
|
||||||
|
>
|
||||||
|
{meta?.title}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 z-0 overflow-hidden"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-anim="shine"
|
||||||
|
className="absolute inset-y-0 block w-1/3 opacity-0 blur-[10px] [background:linear-gradient(100deg,transparent_0%,rgba(255,255,255,0.55)_50%,transparent_100%)]"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-2.5">
|
||||||
|
{meta?.priority && (
|
||||||
|
<span data-anim="badge" className="inline-flex">
|
||||||
|
<Status status={meta.priority}>{meta.priority}</Status>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{meta?.type && (
|
||||||
|
<span
|
||||||
|
data-anim="badge"
|
||||||
|
className="rounded-full bg-white/20 px-3 py-1 text-xs font-semibold capitalize text-white ring-1 ring-white/25 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
{meta.type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{meta?.createdAt && (
|
||||||
|
<span
|
||||||
|
data-anim="badge"
|
||||||
|
className="rounded-full bg-white/15 px-3 py-1 text-xs font-medium text-white/85 ring-1 ring-white/15"
|
||||||
|
>
|
||||||
|
{meta.createdAt}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meta?.link && (
|
||||||
|
<Link
|
||||||
|
data-anim="badge"
|
||||||
|
href={meta.link}
|
||||||
|
className="mt-4 inline-flex items-center gap-2 rounded-xl bg-white/95 px-4 py-2 text-sm font-semibold text-primary shadow-theme-sm transition-transform hover:scale-[1.03]"
|
||||||
|
>
|
||||||
|
Open link
|
||||||
|
<span aria-hidden>↗</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Detail grid */}
|
||||||
|
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{details.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
data-anim="card"
|
||||||
|
className="group relative overflow-hidden rounded-2xl border border-stroke/60 bg-white p-5 shadow-theme-xs transition-shadow hover:shadow-1 dark:border-dark-3 dark:bg-gray-dark"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -right-6 -top-6 size-16 rounded-full bg-primary/5 blur-xl transition-opacity group-hover:opacity-100 dark:bg-primary/10"
|
||||||
|
/>
|
||||||
|
<small className="text-xs font-medium uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
||||||
|
{item.label}
|
||||||
|
</small>
|
||||||
|
<div className="mt-2">
|
||||||
|
{item.kind === "status" ? (
|
||||||
|
<Status status={item.tone ?? ""}>{item.value}</Status>
|
||||||
|
) : (
|
||||||
|
<p className="break-words text-base font-semibold text-dark dark:text-white">
|
||||||
|
{item.value}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Image + message */}
|
||||||
|
{(meta?.image || meta?.hasMessage) && (
|
||||||
|
<section
|
||||||
|
data-anim="message"
|
||||||
|
className="overflow-hidden rounded-3xl border border-stroke/60 bg-white shadow-theme-xs dark:border-dark-3 dark:bg-gray-dark"
|
||||||
|
>
|
||||||
|
{meta?.image && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={meta.image}
|
||||||
|
alt={meta.title}
|
||||||
|
className="max-h-72 w-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{meta?.hasMessage && (
|
||||||
|
<div className="p-6 md:p-8">
|
||||||
|
<h2 className="mb-4 flex items-center gap-2 text-lg font-bold text-dark dark:text-white">
|
||||||
|
<span className="inline-block h-5 w-1 rounded-full bg-gradient-to-b from-primary to-blue-700" />
|
||||||
|
Message
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className="text-base leading-relaxed text-dark-4 dark:text-dark-6 [&_a]:text-primary [&_a]:underline"
|
||||||
|
dangerouslySetInnerHTML={{ __html: meta.messageHtml }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useGetNotificationDetails } from "@/hooks/queries/useNotificationQueries";
|
||||||
|
import { sanitizeHtmlWithStyles } from "@/lib/utils";
|
||||||
|
import { BreadcrumbItem } from "@/types/shared";
|
||||||
|
import { capitalize, unixToDate } from "@/utils/shared";
|
||||||
|
import gsap from "gsap";
|
||||||
|
import { use, useLayoutEffect, useMemo, useRef } from "react";
|
||||||
|
|
||||||
|
interface IParams {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDetailItem {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
kind?: "text" | "status";
|
||||||
|
tone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefersReducedMotion = () =>
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
export const useNotificationDetailsPresenter = ({ params }: IParams) => {
|
||||||
|
const { id } = use(params);
|
||||||
|
const { data, isLoading } = useGetNotificationDetails(id);
|
||||||
|
const notification = data?.data;
|
||||||
|
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const breadcrumbItems: BreadcrumbItem[] = [
|
||||||
|
{ href: "/", name: "Dashboard" },
|
||||||
|
{ href: "/notification-history", name: "Notification history" },
|
||||||
|
{ href: `/notification-history/${id}`, name: "Notification details" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const meta = useMemo(() => {
|
||||||
|
if (!notification) return null;
|
||||||
|
return {
|
||||||
|
title: notification.title || "Notification",
|
||||||
|
messageHtml: sanitizeHtmlWithStyles(notification.message || ""),
|
||||||
|
hasMessage: !!notification.message,
|
||||||
|
image: notification.image,
|
||||||
|
priority: notification.priority,
|
||||||
|
type: notification.type,
|
||||||
|
status: notification.status,
|
||||||
|
link: notification.link,
|
||||||
|
createdAt: unixToDate({ unix: notification.created_at, hasTime: true }),
|
||||||
|
recipient: notification.recipient?.full_name,
|
||||||
|
};
|
||||||
|
}, [notification]);
|
||||||
|
|
||||||
|
const details: IDetailItem[] = useMemo(() => {
|
||||||
|
if (!notification) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "Recipient",
|
||||||
|
value: notification.recipient?.full_name || "—",
|
||||||
|
},
|
||||||
|
{ label: "Event type", value: notification.event_type || "—" },
|
||||||
|
{
|
||||||
|
label: "Status",
|
||||||
|
value: capitalize(notification.status || "—"),
|
||||||
|
kind: "status",
|
||||||
|
tone: notification.status,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Priority",
|
||||||
|
value: capitalize(notification.priority || "—"),
|
||||||
|
kind: "status",
|
||||||
|
tone: notification.priority,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Channel type",
|
||||||
|
value: capitalize(notification.type || "—"),
|
||||||
|
kind: "status",
|
||||||
|
tone: notification.type,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Seen",
|
||||||
|
value: notification.seen ? "Yes" : "No",
|
||||||
|
kind: "status",
|
||||||
|
tone: notification.seen ? "success" : "failed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Seen at",
|
||||||
|
value: unixToDate({ unix: notification.seen_at ?? 0, hasTime: true }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Scheduled",
|
||||||
|
value: notification.is_scheduled ? "Yes" : "No",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [notification]);
|
||||||
|
|
||||||
|
// Complex GSAP intro + ambient motion, scoped to the page root.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (isLoading || !notification) return;
|
||||||
|
|
||||||
|
const root = rootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const ctx = gsap.context((self) => {
|
||||||
|
if (prefersReducedMotion()) return;
|
||||||
|
|
||||||
|
const q = self.selector!;
|
||||||
|
|
||||||
|
// Entrance timeline
|
||||||
|
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
|
||||||
|
|
||||||
|
tl.from(root, { y: 28, duration: 0.7 }, 0)
|
||||||
|
.from(
|
||||||
|
q("[data-anim='breadcrumb']"),
|
||||||
|
{ y: -12, opacity: 0, duration: 0.5 },
|
||||||
|
0.05,
|
||||||
|
)
|
||||||
|
.from(
|
||||||
|
q("[data-anim='hero']"),
|
||||||
|
{ y: 40, opacity: 0, duration: 0.8 },
|
||||||
|
0.1,
|
||||||
|
)
|
||||||
|
.from(
|
||||||
|
q("[data-anim='hero-icon']"),
|
||||||
|
{
|
||||||
|
scale: 0,
|
||||||
|
rotate: -120,
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.8,
|
||||||
|
ease: "back.out(2)",
|
||||||
|
},
|
||||||
|
0.3,
|
||||||
|
)
|
||||||
|
.from(
|
||||||
|
q("[data-title-inner]"),
|
||||||
|
{ y: 32, opacity: 0, duration: 0.7, ease: "back.out(1.6)" },
|
||||||
|
0.4,
|
||||||
|
)
|
||||||
|
.from(
|
||||||
|
q("[data-anim='badge']"),
|
||||||
|
{
|
||||||
|
y: 16,
|
||||||
|
scale: 0.8,
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.5,
|
||||||
|
stagger: 0.08,
|
||||||
|
ease: "back.out(1.8)",
|
||||||
|
},
|
||||||
|
0.55,
|
||||||
|
)
|
||||||
|
.from(
|
||||||
|
q("[data-anim='card']"),
|
||||||
|
{
|
||||||
|
y: 36,
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.94,
|
||||||
|
duration: 0.6,
|
||||||
|
stagger: 0.07,
|
||||||
|
ease: "back.out(1.4)",
|
||||||
|
},
|
||||||
|
0.5,
|
||||||
|
)
|
||||||
|
.from(
|
||||||
|
q("[data-anim='message']"),
|
||||||
|
{ y: 30, opacity: 0, duration: 0.7 },
|
||||||
|
0.75,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ambient aurora rotation
|
||||||
|
const aurora = q("[data-anim='aurora']");
|
||||||
|
if (aurora.length) {
|
||||||
|
gsap.to(aurora, {
|
||||||
|
rotate: 360,
|
||||||
|
duration: 44,
|
||||||
|
ease: "none",
|
||||||
|
repeat: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating orbs
|
||||||
|
gsap.to(q("[data-orb='a']"), {
|
||||||
|
x: 40,
|
||||||
|
y: -28,
|
||||||
|
scale: 1.18,
|
||||||
|
duration: 7,
|
||||||
|
repeat: -1,
|
||||||
|
yoyo: true,
|
||||||
|
ease: "sine.inOut",
|
||||||
|
});
|
||||||
|
gsap.to(q("[data-orb='b']"), {
|
||||||
|
x: -34,
|
||||||
|
y: 26,
|
||||||
|
scale: 1.12,
|
||||||
|
duration: 8.5,
|
||||||
|
repeat: -1,
|
||||||
|
yoyo: true,
|
||||||
|
ease: "sine.inOut",
|
||||||
|
});
|
||||||
|
gsap.to(q("[data-orb='c']"), {
|
||||||
|
x: 24,
|
||||||
|
y: 18,
|
||||||
|
scale: 1.08,
|
||||||
|
duration: 9.5,
|
||||||
|
repeat: -1,
|
||||||
|
yoyo: true,
|
||||||
|
ease: "sine.inOut",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Title shine sweep
|
||||||
|
const shine = q("[data-anim='shine']");
|
||||||
|
if (shine.length) {
|
||||||
|
const shineTl = gsap.timeline({ repeat: -1, repeatDelay: 3.5 });
|
||||||
|
shineTl
|
||||||
|
.set(shine, { xPercent: -160, opacity: 0 })
|
||||||
|
.to(shine, { opacity: 1, duration: 0.2, delay: 1.6 })
|
||||||
|
.to(shine, { xPercent: 260, duration: 1.8, ease: "power2.inOut" }, "<")
|
||||||
|
.to(shine, { opacity: 0, duration: 0.2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card hover lift + magnetic mouse parallax on the hero orbs
|
||||||
|
const cards = q("[data-anim='card']");
|
||||||
|
cards.forEach((card: Element) => {
|
||||||
|
const el = card as HTMLElement;
|
||||||
|
const enter = () =>
|
||||||
|
gsap.to(el, { y: -6, duration: 0.35, ease: "power2.out" });
|
||||||
|
const leave = () =>
|
||||||
|
gsap.to(el, { y: 0, duration: 0.45, ease: "power2.out" });
|
||||||
|
el.addEventListener("mouseenter", enter);
|
||||||
|
el.addEventListener("mouseleave", leave);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
const r = root.getBoundingClientRect();
|
||||||
|
const px = (e.clientX - r.left) / r.width - 0.5;
|
||||||
|
const py = (e.clientY - r.top) / r.height - 0.5;
|
||||||
|
gsap.to(q("[data-orb='a']"), {
|
||||||
|
xPercent: px * 22,
|
||||||
|
yPercent: py * 18,
|
||||||
|
duration: 1.2,
|
||||||
|
ease: "power2.out",
|
||||||
|
overwrite: "auto",
|
||||||
|
});
|
||||||
|
gsap.to(q("[data-orb='b']"), {
|
||||||
|
xPercent: px * -18,
|
||||||
|
yPercent: py * 20,
|
||||||
|
duration: 1.3,
|
||||||
|
ease: "power2.out",
|
||||||
|
overwrite: "auto",
|
||||||
|
});
|
||||||
|
gsap.to(q("[data-orb='c']"), {
|
||||||
|
xPercent: px * 14,
|
||||||
|
yPercent: py * -14,
|
||||||
|
duration: 1.4,
|
||||||
|
ease: "power2.out",
|
||||||
|
overwrite: "auto",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
root.addEventListener("mousemove", onMove);
|
||||||
|
|
||||||
|
return () => root.removeEventListener("mousemove", onMove);
|
||||||
|
}, rootRef);
|
||||||
|
|
||||||
|
return () => ctx.revert();
|
||||||
|
}, [isLoading, notification]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading,
|
||||||
|
rootRef,
|
||||||
|
breadcrumbItems,
|
||||||
|
notification,
|
||||||
|
meta,
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -7,9 +7,10 @@ import useBenefitsStepPresenter, {
|
|||||||
|
|
||||||
interface BenefitsStepProps {
|
interface BenefitsStepProps {
|
||||||
initialData?: IBenefitsStepFields;
|
initialData?: IBenefitsStepFields;
|
||||||
|
onValidityChange?: (valid: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BenefitsStep = forwardRef<unknown, BenefitsStepProps>(({ initialData }, ref) => {
|
const BenefitsStep = forwardRef<unknown, BenefitsStepProps>(({ initialData, onValidityChange }, ref) => {
|
||||||
const {
|
const {
|
||||||
errors,
|
errors,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -19,7 +20,7 @@ const BenefitsStep = forwardRef<unknown, BenefitsStepProps>(({ initialData }, re
|
|||||||
append,
|
append,
|
||||||
remove,
|
remove,
|
||||||
control,
|
control,
|
||||||
} = useBenefitsStepPresenter(initialData, ref);
|
} = useBenefitsStepPresenter(initialData, ref, onValidityChange);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionStep<IBenefitsStepFields>
|
<SectionStep<IBenefitsStepFields>
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import useChallengesStepPresenter, {
|
|||||||
|
|
||||||
interface ChallengesStepProps {
|
interface ChallengesStepProps {
|
||||||
initialData?: IChallengesStepFields;
|
initialData?: IChallengesStepFields;
|
||||||
|
onValidityChange?: (valid: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChallengesStep = forwardRef<unknown, ChallengesStepProps>(
|
const ChallengesStep = forwardRef<unknown, ChallengesStepProps>(
|
||||||
({ initialData }, ref) => {
|
({ initialData, onValidityChange }, ref) => {
|
||||||
const {
|
const {
|
||||||
errors,
|
errors,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -20,7 +21,7 @@ const ChallengesStep = forwardRef<unknown, ChallengesStepProps>(
|
|||||||
append,
|
append,
|
||||||
remove,
|
remove,
|
||||||
control,
|
control,
|
||||||
} = useChallengesStepPresenter(initialData, ref);
|
} = useChallengesStepPresenter(initialData, ref, onValidityChange);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionStep<IChallengesStepFields>
|
<SectionStep<IChallengesStepFields>
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import useChartStepPresenter from "./useChartStepPresenter";
|
|||||||
|
|
||||||
interface ChartStepProps {
|
interface ChartStepProps {
|
||||||
initialData?: any;
|
initialData?: any;
|
||||||
|
onValidityChange?: (valid: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChartStep = forwardRef<unknown, ChartStepProps>(
|
const ChartStep = forwardRef<unknown, ChartStepProps>(
|
||||||
({ initialData }, ref) => {
|
({ initialData, onValidityChange }, ref) => {
|
||||||
const {
|
const {
|
||||||
errors,
|
errors,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -23,7 +24,7 @@ const ChartStep = forwardRef<unknown, ChartStepProps>(
|
|||||||
append,
|
append,
|
||||||
remove,
|
remove,
|
||||||
watch,
|
watch,
|
||||||
} = useChartStepPresenter(initialData, ref);
|
} = useChartStepPresenter(initialData, ref, onValidityChange);
|
||||||
|
|
||||||
const chartTitle = watch("title");
|
const chartTitle = watch("title");
|
||||||
const chartData = watch("data");
|
const chartData = watch("data");
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import useContactsStepPresenter from "./useContactsStepPresenter";
|
|||||||
|
|
||||||
interface ContactsStepProps {
|
interface ContactsStepProps {
|
||||||
initialData?: any;
|
initialData?: any;
|
||||||
|
onValidityChange?: (valid: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContactsStep = forwardRef<unknown, ContactsStepProps>(
|
const ContactsStep = forwardRef<unknown, ContactsStepProps>(
|
||||||
({ initialData }, ref) => {
|
({ initialData, onValidityChange }, ref) => {
|
||||||
const { errors, handleSubmit, onSubmit, register, fields } =
|
const { errors, handleSubmit, onSubmit, register, fields } =
|
||||||
useContactsStepPresenter(initialData, ref);
|
useContactsStepPresenter(initialData, ref, onValidityChange);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import useFeaturesStepPresenter, {
|
|||||||
|
|
||||||
interface FeaturesStepProps {
|
interface FeaturesStepProps {
|
||||||
initialData?: IFeaturesStepFields;
|
initialData?: IFeaturesStepFields;
|
||||||
|
onValidityChange?: (valid: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FeaturesStep = forwardRef<unknown, FeaturesStepProps>(
|
const FeaturesStep = forwardRef<unknown, FeaturesStepProps>(
|
||||||
({ initialData }, ref) => {
|
({ initialData, onValidityChange }, ref) => {
|
||||||
const {
|
const {
|
||||||
errors,
|
errors,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -20,7 +21,7 @@ const FeaturesStep = forwardRef<unknown, FeaturesStepProps>(
|
|||||||
append,
|
append,
|
||||||
remove,
|
remove,
|
||||||
control,
|
control,
|
||||||
} = useFeaturesStepPresenter(initialData, ref);
|
} = useFeaturesStepPresenter(initialData, ref, onValidityChange);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionStep<IFeaturesStepFields>
|
<SectionStep<IFeaturesStepFields>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import InputGroup from "@/components/FormElements/InputGroup";
|
||||||
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
|
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
|
||||||
|
import { createPhoneFieldRules } from "@/components/FormElements/PhoneNumberInput";
|
||||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||||
import { FormErrorMessages } from "@/constants/Texts";
|
import { FormErrorMessages } from "@/constants/Texts";
|
||||||
import { forwardRef } from "react";
|
import { forwardRef } from "react";
|
||||||
@@ -8,13 +9,15 @@ import useFooterStepPresenter from "./useFooterStepPresenter";
|
|||||||
|
|
||||||
interface FooterStepProps {
|
interface FooterStepProps {
|
||||||
initialData?: any;
|
initialData?: any;
|
||||||
|
onValidityChange?: (valid: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FooterStep = forwardRef<unknown, FooterStepProps>(
|
const FooterStep = forwardRef<unknown, FooterStepProps>(
|
||||||
({ initialData }, ref) => {
|
({ initialData, onValidityChange }, ref) => {
|
||||||
const { errors, handleSubmit, onSubmit, register } = useFooterStepPresenter(
|
const { errors, handleSubmit, onSubmit, register } = useFooterStepPresenter(
|
||||||
initialData,
|
initialData,
|
||||||
ref,
|
ref,
|
||||||
|
onValidityChange,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -44,11 +47,16 @@ const FooterStep = forwardRef<unknown, FooterStepProps>(
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...register("phone")}
|
{...register("phone", createPhoneFieldRules())}
|
||||||
label="Phone Number"
|
label="Phone Number"
|
||||||
name="phone"
|
name="phone"
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
|
{errors.phone && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">
|
||||||
|
{errors.phone.message as string}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...register("location")}
|
{...register("location")}
|
||||||
label="Location"
|
label="Location"
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import useHeroStepPresenter from "./useHeroStepPresenter";
|
|||||||
|
|
||||||
interface HeroStepProps {
|
interface HeroStepProps {
|
||||||
initialData?: any;
|
initialData?: any;
|
||||||
|
onValidityChange?: (valid: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HeroStep = forwardRef<unknown, HeroStepProps>(({ initialData }, ref) => {
|
const HeroStep = forwardRef<unknown, HeroStepProps>(({ initialData, onValidityChange }, ref) => {
|
||||||
const { errors, handleSubmit, onSubmit, register } = useHeroStepPresenter(
|
const { errors, handleSubmit, onSubmit, register } = useHeroStepPresenter(
|
||||||
initialData,
|
initialData,
|
||||||
ref,
|
ref,
|
||||||
|
onValidityChange,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -101,6 +103,10 @@ const HeroStep = forwardRef<unknown, HeroStepProps>(({ initialData }, ref) => {
|
|||||||
value: 200,
|
value: 200,
|
||||||
message: FormErrorMessages.maxLength(200),
|
message: FormErrorMessages.maxLength(200),
|
||||||
},
|
},
|
||||||
|
pattern: {
|
||||||
|
value: REGEX.URL,
|
||||||
|
message: FormErrorMessages.invalidPattern,
|
||||||
|
},
|
||||||
})}
|
})}
|
||||||
label="Button Link"
|
label="Button Link"
|
||||||
name="button_link"
|
name="button_link"
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ const MarketingForm = ({
|
|||||||
watchPageType,
|
watchPageType,
|
||||||
pageTypeErrors,
|
pageTypeErrors,
|
||||||
isCompany,
|
isCompany,
|
||||||
|
isCurrentStepValid,
|
||||||
|
stepValidityHandlers,
|
||||||
setValue,
|
setValue,
|
||||||
heroRef,
|
heroRef,
|
||||||
chartRef,
|
chartRef,
|
||||||
@@ -177,28 +179,53 @@ const MarketingForm = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: step === 0 ? "block" : "none" }}>
|
<div style={{ display: step === 0 ? "block" : "none" }}>
|
||||||
<HeroStep ref={heroRef} initialData={formData.hero} />
|
<HeroStep
|
||||||
|
ref={heroRef}
|
||||||
|
initialData={formData.hero}
|
||||||
|
onValidityChange={stepValidityHandlers[0]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: step === 1 ? "block" : "none" }}>
|
<div style={{ display: step === 1 ? "block" : "none" }}>
|
||||||
<ChartStep ref={chartRef} initialData={formData.chart} />
|
<ChartStep
|
||||||
|
ref={chartRef}
|
||||||
|
initialData={formData.chart}
|
||||||
|
onValidityChange={stepValidityHandlers[1]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: step === 2 ? "block" : "none" }}>
|
<div style={{ display: step === 2 ? "block" : "none" }}>
|
||||||
<FeaturesStep ref={featuresRef} initialData={formData.features} />
|
<FeaturesStep
|
||||||
|
ref={featuresRef}
|
||||||
|
initialData={formData.features}
|
||||||
|
onValidityChange={stepValidityHandlers[2]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: step === 3 ? "block" : "none" }}>
|
<div style={{ display: step === 3 ? "block" : "none" }}>
|
||||||
<ChallengesStep
|
<ChallengesStep
|
||||||
ref={challengesRef}
|
ref={challengesRef}
|
||||||
initialData={formData.challenges}
|
initialData={formData.challenges}
|
||||||
|
onValidityChange={stepValidityHandlers[3]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: step === 4 ? "block" : "none" }}>
|
<div style={{ display: step === 4 ? "block" : "none" }}>
|
||||||
<BenefitsStep ref={benefitsRef} initialData={formData.advantages} />
|
<BenefitsStep
|
||||||
|
ref={benefitsRef}
|
||||||
|
initialData={formData.advantages}
|
||||||
|
onValidityChange={stepValidityHandlers[4]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: step === 5 ? "block" : "none" }}>
|
<div style={{ display: step === 5 ? "block" : "none" }}>
|
||||||
<ContactsStep ref={contactsRef} initialData={formData.contact} />
|
<ContactsStep
|
||||||
|
ref={contactsRef}
|
||||||
|
initialData={formData.contact}
|
||||||
|
onValidityChange={stepValidityHandlers[5]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: step === 6 ? "block" : "none" }}>
|
<div style={{ display: step === 6 ? "block" : "none" }}>
|
||||||
<FooterStep ref={footerRef} initialData={formData.footer} />
|
<FooterStep
|
||||||
|
ref={footerRef}
|
||||||
|
initialData={formData.footer}
|
||||||
|
onValidityChange={stepValidityHandlers[6]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Stepper
|
<Stepper
|
||||||
@@ -207,6 +234,7 @@ const MarketingForm = ({
|
|||||||
onStepChange={handleStepChange}
|
onStepChange={handleStepChange}
|
||||||
onSubmit={handleSubmitAll}
|
onSubmit={handleSubmitAll}
|
||||||
isSubmitting={isPending}
|
isSubmitting={isPending}
|
||||||
|
nextDisabled={!isCurrentStepValid}
|
||||||
/>
|
/>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { TrashIcon } from "@/assets/icons";
|
|||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import InputGroup from "@/components/FormElements/InputGroup";
|
||||||
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
|
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
|
||||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||||
import { REGEX } from "@/constants/regex";
|
import ImageUploadField from "@/components/ui/ImageUploadField";
|
||||||
import { FormErrorMessages } from "@/constants/Texts";
|
import { FormErrorMessages } from "@/constants/Texts";
|
||||||
import {
|
import {
|
||||||
Control,
|
Control,
|
||||||
|
Controller,
|
||||||
FieldErrors,
|
FieldErrors,
|
||||||
FieldValues,
|
FieldValues,
|
||||||
Path,
|
Path,
|
||||||
@@ -16,6 +17,20 @@ import {
|
|||||||
UseFormRegister,
|
UseFormRegister,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
|
|
||||||
|
const ICON_ACCEPT =
|
||||||
|
"image/png,image/jpeg,image/jpg,image/webp,image/svg+xml,image/gif";
|
||||||
|
const ICON_ALLOWED_TYPES = [
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/webp",
|
||||||
|
"image/svg+xml",
|
||||||
|
"image/gif",
|
||||||
|
];
|
||||||
|
|
||||||
|
const slugify = (value: string) =>
|
||||||
|
value.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||||||
|
|
||||||
interface IProps<T extends FieldValues> {
|
interface IProps<T extends FieldValues> {
|
||||||
title: string;
|
title: string;
|
||||||
fields: any[];
|
fields: any[];
|
||||||
@@ -39,6 +54,7 @@ const SectionStep = <T extends FieldValues>({
|
|||||||
errors,
|
errors,
|
||||||
append,
|
append,
|
||||||
remove,
|
remove,
|
||||||
|
control,
|
||||||
fieldName,
|
fieldName,
|
||||||
defaultValues,
|
defaultValues,
|
||||||
}: IProps<T>) => {
|
}: IProps<T>) => {
|
||||||
@@ -82,22 +98,30 @@ const SectionStep = <T extends FieldValues>({
|
|||||||
key={field.id}
|
key={field.id}
|
||||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2"
|
className="grid grid-cols-1 gap-4 sm:grid-cols-2"
|
||||||
>
|
>
|
||||||
<InputGroup
|
<Controller
|
||||||
{...register(`cards.${index}.icon` as Path<T>, {
|
control={control}
|
||||||
|
name={`cards.${index}.icon` as Path<T>}
|
||||||
|
rules={{
|
||||||
required: {
|
required: {
|
||||||
message: FormErrorMessages.required,
|
message: FormErrorMessages.required,
|
||||||
value: true,
|
value: true,
|
||||||
},
|
},
|
||||||
pattern: {
|
}}
|
||||||
message: FormErrorMessages.invalidPattern,
|
render={({ field, fieldState }) => (
|
||||||
value: REGEX.URL,
|
<ImageUploadField
|
||||||
},
|
value={(field.value as string) ?? ""}
|
||||||
})}
|
onChange={field.onChange}
|
||||||
label="Icon"
|
label="Icon"
|
||||||
name={`cards.${index}.icon`}
|
inputId={`${slugify(title)}-card-icon-${index}`}
|
||||||
required
|
title="Click to upload an icon"
|
||||||
type="text"
|
hint="PNG, JPG, WEBP, SVG, GIF up to 5MB"
|
||||||
placeholder="Enter Icon URL"
|
recommendation="Recommended size: 128 x 128"
|
||||||
|
fallbackFileName="Current icon"
|
||||||
|
accept={ICON_ACCEPT}
|
||||||
|
allowedTypes={ICON_ALLOWED_TYPES}
|
||||||
|
errorMessage={fieldState.error?.message as string}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...register(`cards.${index}.title` as Path<T>, {
|
{...register(`cards.${index}.title` as Path<T>, {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { ForwardedRef, useImperativeHandle } from "react";
|
import { ForwardedRef, useImperativeHandle } from "react";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import useReportStepValidity from "./useReportStepValidity";
|
||||||
|
|
||||||
export interface IBenefitsStepFields {
|
export interface IBenefitsStepFields {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -15,21 +16,25 @@ export interface IBenefitsStepFields {
|
|||||||
const useBenefitsStepPresenter = (
|
const useBenefitsStepPresenter = (
|
||||||
initialData?: IBenefitsStepFields,
|
initialData?: IBenefitsStepFields,
|
||||||
ref?: ForwardedRef<unknown>,
|
ref?: ForwardedRef<unknown>,
|
||||||
|
onValidityChange?: (valid: boolean) => void,
|
||||||
) => {
|
) => {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
control,
|
control,
|
||||||
watch,
|
watch,
|
||||||
formState: { errors },
|
formState: { errors, isValid },
|
||||||
getValues,
|
getValues,
|
||||||
trigger,
|
trigger,
|
||||||
} = useForm<IBenefitsStepFields>({
|
} = useForm<IBenefitsStepFields>({
|
||||||
defaultValues: initialData || {
|
defaultValues: initialData || {
|
||||||
cards: [{ icon: "", title: "", description: "" }],
|
cards: [{ icon: "", title: "", description: "" }],
|
||||||
},
|
},
|
||||||
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useReportStepValidity(isValid, onValidityChange);
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({
|
const { fields, append, remove } = useFieldArray({
|
||||||
control,
|
control,
|
||||||
name: "cards",
|
name: "cards",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { ForwardedRef, useImperativeHandle } from "react";
|
import { ForwardedRef, useImperativeHandle } from "react";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import useReportStepValidity from "./useReportStepValidity";
|
||||||
|
|
||||||
export interface IChallengesStepFields {
|
export interface IChallengesStepFields {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -15,21 +16,25 @@ export interface IChallengesStepFields {
|
|||||||
const useChallengesStepPresenter = (
|
const useChallengesStepPresenter = (
|
||||||
initialData?: IChallengesStepFields,
|
initialData?: IChallengesStepFields,
|
||||||
ref?: ForwardedRef<unknown>,
|
ref?: ForwardedRef<unknown>,
|
||||||
|
onValidityChange?: (valid: boolean) => void,
|
||||||
) => {
|
) => {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
control,
|
control,
|
||||||
watch,
|
watch,
|
||||||
formState: { errors },
|
formState: { errors, isValid },
|
||||||
getValues,
|
getValues,
|
||||||
trigger,
|
trigger,
|
||||||
} = useForm<IChallengesStepFields>({
|
} = useForm<IChallengesStepFields>({
|
||||||
defaultValues: initialData || {
|
defaultValues: initialData || {
|
||||||
cards: [{ icon: "", title: "", description: "" }],
|
cards: [{ icon: "", title: "", description: "" }],
|
||||||
},
|
},
|
||||||
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useReportStepValidity(isValid, onValidityChange);
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({
|
const { fields, append, remove } = useFieldArray({
|
||||||
control,
|
control,
|
||||||
name: "cards",
|
name: "cards",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { ForwardedRef, useImperativeHandle } from "react";
|
import { ForwardedRef, useImperativeHandle } from "react";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import useReportStepValidity from "./useReportStepValidity";
|
||||||
|
|
||||||
export interface IChartStepFields {
|
export interface IChartStepFields {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -11,21 +12,25 @@ export interface IChartStepFields {
|
|||||||
const useChartStepPresenter = (
|
const useChartStepPresenter = (
|
||||||
initialData?: IChartStepFields,
|
initialData?: IChartStepFields,
|
||||||
ref?: ForwardedRef<unknown>,
|
ref?: ForwardedRef<unknown>,
|
||||||
|
onValidityChange?: (valid: boolean) => void,
|
||||||
) => {
|
) => {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
control,
|
control,
|
||||||
watch,
|
watch,
|
||||||
formState: { errors },
|
formState: { errors, isValid },
|
||||||
getValues,
|
getValues,
|
||||||
trigger,
|
trigger,
|
||||||
} = useForm<IChartStepFields>({
|
} = useForm<IChartStepFields>({
|
||||||
defaultValues: initialData || {
|
defaultValues: initialData || {
|
||||||
data: [{ key: "", value: 0 }],
|
data: [{ key: "", value: 0 }],
|
||||||
},
|
},
|
||||||
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useReportStepValidity(isValid, onValidityChange);
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({
|
const { fields, append, remove } = useFieldArray({
|
||||||
control,
|
control,
|
||||||
name: "data",
|
name: "data",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { ForwardedRef, useImperativeHandle } from "react";
|
import { ForwardedRef, useImperativeHandle } from "react";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import useReportStepValidity from "./useReportStepValidity";
|
||||||
|
|
||||||
export interface IContactsStepFields {
|
export interface IContactsStepFields {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -17,15 +18,17 @@ export interface IContactsStepFields {
|
|||||||
const useContactsStepPresenter = (
|
const useContactsStepPresenter = (
|
||||||
initialData?: IContactsStepFields,
|
initialData?: IContactsStepFields,
|
||||||
ref?: ForwardedRef<unknown>,
|
ref?: ForwardedRef<unknown>,
|
||||||
|
onValidityChange?: (valid: boolean) => void,
|
||||||
) => {
|
) => {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
control,
|
control,
|
||||||
getValues,
|
getValues,
|
||||||
formState: { errors },
|
formState: { errors, isValid },
|
||||||
trigger,
|
trigger,
|
||||||
} = useForm<IContactsStepFields>({
|
} = useForm<IContactsStepFields>({
|
||||||
|
mode: "onChange",
|
||||||
defaultValues: initialData || {
|
defaultValues: initialData || {
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
@@ -51,6 +54,8 @@ const useContactsStepPresenter = (
|
|||||||
name: "fields",
|
name: "fields",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useReportStepValidity(isValid, onValidityChange);
|
||||||
|
|
||||||
const onSubmit = (data: IContactsStepFields) => {
|
const onSubmit = (data: IContactsStepFields) => {
|
||||||
console.log(data);
|
console.log(data);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { ForwardedRef, useImperativeHandle } from "react";
|
import { ForwardedRef, useImperativeHandle } from "react";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import useReportStepValidity from "./useReportStepValidity";
|
||||||
|
|
||||||
export interface IFeaturesStepFields {
|
export interface IFeaturesStepFields {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -15,21 +16,25 @@ export interface IFeaturesStepFields {
|
|||||||
const useFeaturesStepPresenter = (
|
const useFeaturesStepPresenter = (
|
||||||
initialData?: IFeaturesStepFields,
|
initialData?: IFeaturesStepFields,
|
||||||
ref?: ForwardedRef<unknown>,
|
ref?: ForwardedRef<unknown>,
|
||||||
|
onValidityChange?: (valid: boolean) => void,
|
||||||
) => {
|
) => {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
control,
|
control,
|
||||||
watch,
|
watch,
|
||||||
formState: { errors },
|
formState: { errors, isValid },
|
||||||
getValues,
|
getValues,
|
||||||
trigger,
|
trigger,
|
||||||
} = useForm<IFeaturesStepFields>({
|
} = useForm<IFeaturesStepFields>({
|
||||||
defaultValues: initialData || {
|
defaultValues: initialData || {
|
||||||
cards: [{ icon: "", title: "", description: "" }],
|
cards: [{ icon: "", title: "", description: "" }],
|
||||||
},
|
},
|
||||||
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useReportStepValidity(isValid, onValidityChange);
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({
|
const { fields, append, remove } = useFieldArray({
|
||||||
control,
|
control,
|
||||||
name: "cards",
|
name: "cards",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ForwardedRef, useImperativeHandle } from "react";
|
import { ForwardedRef, useImperativeHandle } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import useReportStepValidity from "./useReportStepValidity";
|
||||||
|
|
||||||
interface FooterFormValues {
|
interface FooterFormValues {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -12,17 +13,21 @@ interface FooterFormValues {
|
|||||||
const useFooterStepPresenter = (
|
const useFooterStepPresenter = (
|
||||||
initialData?: FooterFormValues,
|
initialData?: FooterFormValues,
|
||||||
ref?: ForwardedRef<unknown>,
|
ref?: ForwardedRef<unknown>,
|
||||||
|
onValidityChange?: (valid: boolean) => void,
|
||||||
) => {
|
) => {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors, isValid },
|
||||||
getValues,
|
getValues,
|
||||||
trigger,
|
trigger,
|
||||||
} = useForm<FooterFormValues>({
|
} = useForm<FooterFormValues>({
|
||||||
defaultValues: initialData,
|
defaultValues: initialData,
|
||||||
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useReportStepValidity(isValid, onValidityChange);
|
||||||
|
|
||||||
const onSubmit = (data: FooterFormValues) => {
|
const onSubmit = (data: FooterFormValues) => {
|
||||||
console.log(data);
|
console.log(data);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { ForwardedRef, useImperativeHandle } from "react";
|
import { ForwardedRef, useImperativeHandle } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import useReportStepValidity from "./useReportStepValidity";
|
||||||
|
|
||||||
export interface IHeroStepFields {
|
export interface IHeroStepFields {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -13,17 +14,21 @@ export interface IHeroStepFields {
|
|||||||
const useHeroStepPresenter = (
|
const useHeroStepPresenter = (
|
||||||
initialData?: IHeroStepFields,
|
initialData?: IHeroStepFields,
|
||||||
ref?: ForwardedRef<unknown>,
|
ref?: ForwardedRef<unknown>,
|
||||||
|
onValidityChange?: (valid: boolean) => void,
|
||||||
) => {
|
) => {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors, isValid },
|
||||||
getValues,
|
getValues,
|
||||||
trigger,
|
trigger,
|
||||||
} = useForm<IHeroStepFields>({
|
} = useForm<IHeroStepFields>({
|
||||||
defaultValues: initialData,
|
defaultValues: initialData,
|
||||||
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useReportStepValidity(isValid, onValidityChange);
|
||||||
|
|
||||||
const onSubmit = (data: IHeroStepFields) => {
|
const onSubmit = (data: IHeroStepFields) => {
|
||||||
console.log(data);
|
console.log(data);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Step } from "@/components/ui/Stepper";
|
import { Step } from "@/components/ui/Stepper";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
|
||||||
@@ -13,6 +13,9 @@ interface IPageTypeFields {
|
|||||||
country?: string;
|
country?: string;
|
||||||
key: string;
|
key: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TOTAL_STEPS = 7;
|
||||||
|
|
||||||
const useMarketingForm = (
|
const useMarketingForm = (
|
||||||
initialData: any,
|
initialData: any,
|
||||||
onSubmit: (data: any) => void,
|
onSubmit: (data: any) => void,
|
||||||
@@ -20,6 +23,9 @@ const useMarketingForm = (
|
|||||||
|
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [formData, setFormData] = useState<any>(initialData || {});
|
const [formData, setFormData] = useState<any>(initialData || {});
|
||||||
|
const [stepValidity, setStepValidity] = useState<boolean[]>(() =>
|
||||||
|
new Array(TOTAL_STEPS).fill(false),
|
||||||
|
);
|
||||||
const heroRef = useRef<any>(null);
|
const heroRef = useRef<any>(null);
|
||||||
const chartRef = useRef<any>(null);
|
const chartRef = useRef<any>(null);
|
||||||
const featuresRef = useRef<any>(null);
|
const featuresRef = useRef<any>(null);
|
||||||
@@ -31,12 +37,13 @@ const useMarketingForm = (
|
|||||||
const {
|
const {
|
||||||
register: registerPageType,
|
register: registerPageType,
|
||||||
watch: watchPageType,
|
watch: watchPageType,
|
||||||
formState: { errors: pageTypeErrors },
|
formState: { errors: pageTypeErrors, isValid: isPageTypeValid },
|
||||||
getValues: getPageTypeValues,
|
getValues: getPageTypeValues,
|
||||||
setValue,
|
setValue,
|
||||||
reset,
|
reset,
|
||||||
trigger: triggerPageType,
|
trigger: triggerPageType,
|
||||||
} = useForm<IPageTypeFields>({
|
} = useForm<IPageTypeFields>({
|
||||||
|
mode: "onChange",
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
type: "company",
|
type: "company",
|
||||||
language: "en",
|
language: "en",
|
||||||
@@ -46,6 +53,25 @@ const useMarketingForm = (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stable per-step callbacks so each step form can report its live validity
|
||||||
|
// without recreating handlers (which would re-fire the reporting effect).
|
||||||
|
const stepValidityHandlers = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(
|
||||||
|
{ length: TOTAL_STEPS },
|
||||||
|
(_, index) => (valid: boolean) =>
|
||||||
|
setStepValidity((prev) => {
|
||||||
|
if (prev[index] === valid) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
next[index] = valid;
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isCurrentStepValid = isPageTypeValid && Boolean(stepValidity[step]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialData) {
|
if (initialData) {
|
||||||
const formValues = {
|
const formValues = {
|
||||||
@@ -245,6 +271,8 @@ const useMarketingForm = (
|
|||||||
watchPageType,
|
watchPageType,
|
||||||
pageTypeErrors,
|
pageTypeErrors,
|
||||||
isCompany,
|
isCompany,
|
||||||
|
isCurrentStepValid,
|
||||||
|
stepValidityHandlers,
|
||||||
setValue,
|
setValue,
|
||||||
heroRef,
|
heroRef,
|
||||||
chartRef,
|
chartRef,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridges a step form's reactive `isValid` state up to the parent stepper.
|
||||||
|
* Shared across every step presenter so the reporting logic lives in one place.
|
||||||
|
*/
|
||||||
|
const useReportStepValidity = (
|
||||||
|
isValid: boolean,
|
||||||
|
onValidityChange?: (valid: boolean) => void,
|
||||||
|
) => {
|
||||||
|
useEffect(() => {
|
||||||
|
onValidityChange?.(isValid);
|
||||||
|
}, [isValid, onValidityChange]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useReportStepValidity;
|
||||||
@@ -20,6 +20,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ToastContainer
|
<ToastContainer
|
||||||
|
className="!z-[1000001]"
|
||||||
position="top-right"
|
position="top-right"
|
||||||
autoClose={2500}
|
autoClose={2500}
|
||||||
theme={"colored"}
|
theme={"colored"}
|
||||||
|
|||||||
@@ -29,9 +29,7 @@ interface IProps {
|
|||||||
// Backend has shipped the documents payload as a bare array, an `ApiResponse`
|
// Backend has shipped the documents payload as a bare array, an `ApiResponse`
|
||||||
// wrapper, and a `{ data: { documents: [...] } }` wrapper at different times.
|
// wrapper, and a `{ data: { documents: [...] } }` wrapper at different times.
|
||||||
// Normalize so a real list is never dropped because of a wrapper mismatch.
|
// Normalize so a real list is never dropped because of a wrapper mismatch.
|
||||||
function extractTenderDocuments(
|
function extractTenderDocuments(payload: unknown): TTenderDocumentsResponse[] {
|
||||||
payload: unknown,
|
|
||||||
): TTenderDocumentsResponse[] {
|
|
||||||
if (!payload) return [];
|
if (!payload) return [];
|
||||||
if (Array.isArray(payload)) return payload as TTenderDocumentsResponse[];
|
if (Array.isArray(payload)) return payload as TTenderDocumentsResponse[];
|
||||||
|
|
||||||
@@ -58,12 +56,12 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const selectedLanguage = searchParams.get("lang") || "en";
|
const selectedLanguage = searchParams.get("lang") ?? "";
|
||||||
const { mutate: translateTender, isPending: isTranslating } =
|
const { mutate: translateTender, isPending: isTranslating } =
|
||||||
useTranslateTenderMutation();
|
useTranslateTenderMutation();
|
||||||
const { data, isLoading, isError, isFetching } = useTenderDetailQuery(
|
const { data, isLoading, isError, isFetching } = useTenderDetailQuery(
|
||||||
details,
|
details,
|
||||||
{ language: selectedLanguage },
|
selectedLanguage ? { language: selectedLanguage } : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
@@ -107,7 +105,7 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
const nextLanguage = event.target.value;
|
const nextLanguage = event.target.value;
|
||||||
if (!nextLanguage) return;
|
if (!nextLanguage) return;
|
||||||
|
|
||||||
const prevLanguage = searchParams.get("lang") || "en";
|
const prevLanguage = searchParams.get("lang") ?? "";
|
||||||
if (nextLanguage === prevLanguage) return;
|
if (nextLanguage === prevLanguage) return;
|
||||||
|
|
||||||
userPendingLangRef.current = nextLanguage;
|
userPendingLangRef.current = nextLanguage;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type UseFormRegister,
|
type UseFormRegister,
|
||||||
type UseFormRegisterReturn,
|
type UseFormRegisterReturn,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
|
import { get } from "react-hook-form";
|
||||||
|
|
||||||
type InputGroupProps<T extends FieldValues> = {
|
type InputGroupProps<T extends FieldValues> = {
|
||||||
name: Path<T>;
|
name: Path<T>;
|
||||||
@@ -58,7 +59,7 @@ const InputGroup = <T extends FieldValues>({
|
|||||||
}: InputGroupProps<T>): React.ReactElement => {
|
}: InputGroupProps<T>): React.ReactElement => {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
|
|
||||||
const error = errors && errors[name];
|
const error = errors && get(errors, name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
|
|||||||
@@ -133,13 +133,32 @@ type PropsType<T extends FieldValues> = {
|
|||||||
searchQueryKey?: readonly unknown[];
|
searchQueryKey?: readonly unknown[];
|
||||||
/** Loads options for the debounced search term. Required when `searchMode` is `"server"`. */
|
/** Loads options for the debounced search term. Required when `searchMode` is `"server"`. */
|
||||||
searchFn?: (query: string) => Promise<ILabelValue[]>;
|
searchFn?: (query: string) => Promise<ILabelValue[]>;
|
||||||
|
/**
|
||||||
|
* External lazy-load mode: the parent owns `items`, searching and pagination.
|
||||||
|
* Providing `onSearch` and/or `onLoadMore` opts in — the dropdown renders a search
|
||||||
|
* box, streams `items` as-is and requests more on scroll. Pairs with an infinite query.
|
||||||
|
*/
|
||||||
|
onSearch?: (query: string) => void;
|
||||||
|
/** Fetch the next page; called when the option list is scrolled near its end. */
|
||||||
|
onLoadMore?: () => void;
|
||||||
|
/** Whether more pages are available to lazy-load. */
|
||||||
|
hasMore?: boolean;
|
||||||
|
/** Initial / search fetch in progress (external mode). */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Next-page fetch in progress (external mode). */
|
||||||
|
isLoadingMore?: boolean;
|
||||||
} & (
|
} & (
|
||||||
| { placeholder?: string; defaultValue?: string }
|
| { placeholder?: string; defaultValue?: string }
|
||||||
| { placeholder: string; defaultValue?: string }
|
| { placeholder: string; defaultValue?: string }
|
||||||
) &
|
) &
|
||||||
Partial<UseFormRegisterReturn>;
|
Partial<UseFormRegisterReturn>;
|
||||||
|
|
||||||
export function Select<T extends FieldValues>({
|
export function Select<T extends FieldValues>(selectProps: PropsType<T>) {
|
||||||
|
const hasControlledValue = Object.prototype.hasOwnProperty.call(
|
||||||
|
selectProps,
|
||||||
|
"value",
|
||||||
|
);
|
||||||
|
const {
|
||||||
items,
|
items,
|
||||||
label,
|
label,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
@@ -163,11 +182,22 @@ export function Select<T extends FieldValues>({
|
|||||||
searchDebounceMs = 300,
|
searchDebounceMs = 300,
|
||||||
searchQueryKey,
|
searchQueryKey,
|
||||||
searchFn,
|
searchFn,
|
||||||
|
onSearch,
|
||||||
|
onLoadMore,
|
||||||
|
hasMore = false,
|
||||||
|
isLoading = false,
|
||||||
|
isLoadingMore = false,
|
||||||
register: _register,
|
register: _register,
|
||||||
...props
|
...props
|
||||||
}: PropsType<T>) {
|
} = selectProps;
|
||||||
void _register;
|
void _register;
|
||||||
|
|
||||||
|
/** Parent-controlled list: search + pagination handled outside, `items` consumed as-is. */
|
||||||
|
const isExternal =
|
||||||
|
typeof onSearch === "function" || typeof onLoadMore === "function";
|
||||||
|
/** Both `searchable` and external mode render the in-dropdown search box. */
|
||||||
|
const showSearch = searchable || isExternal;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
process.env.NODE_ENV !== "production" &&
|
process.env.NODE_ENV !== "production" &&
|
||||||
searchable &&
|
searchable &&
|
||||||
@@ -186,6 +216,7 @@ export function Select<T extends FieldValues>({
|
|||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
|
|
||||||
const [isOptionSelected, setIsOptionSelected] = useState(!!defaultValue);
|
const [isOptionSelected, setIsOptionSelected] = useState(!!defaultValue);
|
||||||
const [value, setValue] = useState(defaultValue || "");
|
const [value, setValue] = useState(defaultValue || "");
|
||||||
@@ -197,11 +228,12 @@ export function Select<T extends FieldValues>({
|
|||||||
const error = errors && errors[name];
|
const error = errors && errors[name];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (controlledValue !== undefined) {
|
if (hasControlledValue) {
|
||||||
setValue(controlledValue);
|
const nextValue = controlledValue ?? "";
|
||||||
setIsOptionSelected(!!controlledValue);
|
setValue(nextValue);
|
||||||
|
setIsOptionSelected(!!nextValue);
|
||||||
}
|
}
|
||||||
}, [controlledValue]);
|
}, [controlledValue, hasControlledValue]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isServerMode,
|
isServerMode,
|
||||||
@@ -221,10 +253,21 @@ export function Select<T extends FieldValues>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const listItems = useMemo(() => {
|
const listItems = useMemo(() => {
|
||||||
|
if (isExternal) return items;
|
||||||
if (!searchable) return items;
|
if (!searchable) return items;
|
||||||
if (isServerMode && serverDisplayItems) return serverDisplayItems;
|
if (isServerMode && serverDisplayItems) return serverDisplayItems;
|
||||||
return filterSelectItems(items, query);
|
return filterSelectItems(items, query);
|
||||||
}, [searchable, isServerMode, serverDisplayItems, items, query]);
|
}, [isExternal, searchable, isServerMode, serverDisplayItems, items, query]);
|
||||||
|
|
||||||
|
/** Lazy-load the next page once the option list nears its bottom (external mode). */
|
||||||
|
const handleListScroll = useCallback(() => {
|
||||||
|
if (!isExternal || !onLoadMore || !hasMore || isLoadingMore) return;
|
||||||
|
const el = listRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (el.scrollHeight - el.scrollTop - el.clientHeight < 40) {
|
||||||
|
onLoadMore();
|
||||||
|
}
|
||||||
|
}, [isExternal, onLoadMore, hasMore, isLoadingMore]);
|
||||||
|
|
||||||
const selectedLabel = useMemo(() => {
|
const selectedLabel = useMemo(() => {
|
||||||
const hit = listItems.find((i) => String(i.value) === String(value));
|
const hit = listItems.find((i) => String(i.value) === String(value));
|
||||||
@@ -287,16 +330,16 @@ export function Select<T extends FieldValues>({
|
|||||||
}, [open, listboxId]);
|
}, [open, listboxId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchable || !open) return;
|
if (!showSearch || !open) return;
|
||||||
const idFrame = window.requestAnimationFrame(() => {
|
const idFrame = window.requestAnimationFrame(() => {
|
||||||
searchInputRef.current?.focus();
|
searchInputRef.current?.focus();
|
||||||
searchInputRef.current?.select();
|
searchInputRef.current?.select();
|
||||||
});
|
});
|
||||||
return () => window.cancelAnimationFrame(idFrame);
|
return () => window.cancelAnimationFrame(idFrame);
|
||||||
}, [open, searchable]);
|
}, [open, showSearch]);
|
||||||
|
|
||||||
const emitChange = (next: string) => {
|
const emitChange = (next: string) => {
|
||||||
if (controlledValue === undefined) {
|
if (!hasControlledValue) {
|
||||||
setValue(next);
|
setValue(next);
|
||||||
}
|
}
|
||||||
setIsOptionSelected(!!next);
|
setIsOptionSelected(!!next);
|
||||||
@@ -308,7 +351,7 @@ export function Select<T extends FieldValues>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
if (controlledValue === undefined) {
|
if (!hasControlledValue) {
|
||||||
setValue("");
|
setValue("");
|
||||||
}
|
}
|
||||||
setIsOptionSelected(false);
|
setIsOptionSelected(false);
|
||||||
@@ -323,7 +366,9 @@ export function Select<T extends FieldValues>({
|
|||||||
triggerRef.current?.focus();
|
triggerRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const dropdownItems = searchable ? listItems : items;
|
const dropdownItems = showSearch ? listItems : items;
|
||||||
|
/** Mono value chip next to labels only helps the client/server search modes, not external lists. */
|
||||||
|
const showItemValue = searchable && !isExternal;
|
||||||
|
|
||||||
const dropdown =
|
const dropdown =
|
||||||
mounted &&
|
mounted &&
|
||||||
@@ -345,7 +390,7 @@ export function Select<T extends FieldValues>({
|
|||||||
panelRect.placement === "above" ? "translateY(-100%)" : undefined,
|
panelRect.placement === "above" ? "translateY(-100%)" : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{searchable ? (
|
{showSearch ? (
|
||||||
<div className="border-b border-stroke/50 p-2 dark:border-white/[0.08]">
|
<div className="border-b border-stroke/50 p-2 dark:border-white/[0.08]">
|
||||||
<label htmlFor={`${listboxId}-search`} className="sr-only">
|
<label htmlFor={`${listboxId}-search`} className="sr-only">
|
||||||
{searchPlaceholder}
|
{searchPlaceholder}
|
||||||
@@ -355,7 +400,10 @@ export function Select<T extends FieldValues>({
|
|||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
type="search"
|
type="search"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
if (isExternal) onSearch?.(e.target.value);
|
||||||
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -372,22 +420,27 @@ export function Select<T extends FieldValues>({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<ul className="max-h-[min(18rem,calc(100vh-10rem))] overflow-y-auto overscroll-contain p-1.5">
|
<ul
|
||||||
{searchable && isSearchLoading ? (
|
ref={listRef}
|
||||||
|
onScroll={isExternal ? handleListScroll : undefined}
|
||||||
|
className="max-h-[min(18rem,calc(100vh-10rem))] overflow-y-auto overscroll-contain p-1.5"
|
||||||
|
>
|
||||||
|
{(isExternal ? isLoading : searchable && isSearchLoading) ? (
|
||||||
<li className="flex items-center justify-center gap-2 px-3 py-6 text-sm text-dark-5 dark:text-dark-6">
|
<li className="flex items-center justify-center gap-2 px-3 py-6 text-sm text-dark-5 dark:text-dark-6">
|
||||||
<span className="inline-flex h-4 w-4 animate-spin rounded-full border-2 border-primary/25 border-t-primary" />
|
<span className="inline-flex h-4 w-4 animate-spin rounded-full border-2 border-primary/25 border-t-primary" />
|
||||||
Searching…
|
{isExternal ? "Loading…" : "Searching…"}
|
||||||
</li>
|
</li>
|
||||||
) : searchable && isSearchError ? (
|
) : !isExternal && searchable && isSearchError ? (
|
||||||
<li className="px-3 py-6 text-center text-sm text-error">
|
<li className="px-3 py-6 text-center text-sm text-error">
|
||||||
Could not load options. Try again.
|
Could not load options. Try again.
|
||||||
</li>
|
</li>
|
||||||
) : dropdownItems.length === 0 ? (
|
) : dropdownItems.length === 0 ? (
|
||||||
<li className="px-3 py-6 text-center text-sm text-dark-5 dark:text-dark-6">
|
<li className="px-3 py-6 text-center text-sm text-dark-5 dark:text-dark-6">
|
||||||
{searchable ? "No matches." : "No options."}
|
{showSearch ? "No matches." : "No options."}
|
||||||
</li>
|
</li>
|
||||||
) : (
|
) : (
|
||||||
dropdownItems.map((item) => {
|
<>
|
||||||
|
{dropdownItems.map((item) => {
|
||||||
const v = String(item.value);
|
const v = String(item.value);
|
||||||
const isActive = v === String(value);
|
const isActive = v === String(value);
|
||||||
return (
|
return (
|
||||||
@@ -401,14 +454,14 @@ export function Select<T extends FieldValues>({
|
|||||||
onClick={() => pickItem(v)}
|
onClick={() => pickItem(v)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-start text-sm transition",
|
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-start text-sm transition",
|
||||||
searchable ? "justify-between" : "justify-start",
|
showItemValue ? "justify-between" : "justify-start",
|
||||||
isActive
|
isActive
|
||||||
? "bg-primary/12 font-medium text-primary dark:bg-primary/20 dark:text-white"
|
? "bg-primary/12 font-medium text-primary dark:bg-primary/20 dark:text-white"
|
||||||
: "text-dark hover:bg-stroke/30 dark:text-white dark:hover:bg-white/[0.06]",
|
: "text-dark hover:bg-stroke/30 dark:text-white dark:hover:bg-white/[0.06]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 truncate">{item.label}</span>
|
<span className="min-w-0 truncate">{item.label}</span>
|
||||||
{searchable ? (
|
{showItemValue ? (
|
||||||
<span className="shrink-0 font-mono text-xs text-dark-5 dark:text-dark-6">
|
<span className="shrink-0 font-mono text-xs text-dark-5 dark:text-dark-6">
|
||||||
{v}
|
{v}
|
||||||
</span>
|
</span>
|
||||||
@@ -416,7 +469,13 @@ export function Select<T extends FieldValues>({
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
{isExternal && isLoadingMore ? (
|
||||||
|
<li className="flex items-center justify-center py-2">
|
||||||
|
<span className="inline-flex h-4 w-4 animate-spin rounded-full border-2 border-primary/25 border-t-primary" />
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>,
|
</div>,
|
||||||
@@ -445,7 +504,7 @@ export function Select<T extends FieldValues>({
|
|||||||
data-cy={dataCy}
|
data-cy={dataCy}
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (controlledValue === undefined) {
|
if (!hasControlledValue) {
|
||||||
setValue(e.target.value);
|
setValue(e.target.value);
|
||||||
}
|
}
|
||||||
setIsOptionSelected(!!e.target.value);
|
setIsOptionSelected(!!e.target.value);
|
||||||
@@ -508,7 +567,7 @@ export function Select<T extends FieldValues>({
|
|||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 truncate">
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{value ? (
|
{value ? (
|
||||||
searchable ? (
|
showItemValue ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-dark dark:text-white">
|
<span className="text-dark dark:text-white">
|
||||||
{selectedLabel}
|
{selectedLabel}
|
||||||
@@ -559,7 +618,7 @@ export function Select<T extends FieldValues>({
|
|||||||
{dropdown}
|
{dropdown}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-red mt-1.5 text-sm font-medium">
|
<p className="mt-1.5 text-sm font-medium text-error">
|
||||||
{error.message as string}
|
{error.message as string}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,30 +5,54 @@ import {
|
|||||||
DropdownContent,
|
DropdownContent,
|
||||||
DropdownTrigger,
|
DropdownTrigger,
|
||||||
} from "@/components/ui/dropdown";
|
} from "@/components/ui/dropdown";
|
||||||
|
import { useUser } from "@/contexts";
|
||||||
import Modal from "@/components/ui/modal";
|
import Modal from "@/components/ui/modal";
|
||||||
import { useMarkNotificationAsSeen } from "@/hooks/queries/useNotificationQueries";
|
import Status from "@/components/ui/Status";
|
||||||
|
import {
|
||||||
|
useGetMyNotifications,
|
||||||
|
useMarkNotificationAsSeen,
|
||||||
|
} from "@/hooks/queries/useNotificationQueries";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { TNotificationDetailsResponse } from "@/lib/api/types/NotificationHistory";
|
import { TNotificationDetailsResponse } from "@/lib/api/types/NotificationHistory";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, sanitizeHtmlWithStyles } from "@/lib/utils";
|
||||||
|
import { capitalize, unixToDate } from "@/utils/shared";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { BellIcon } from "./icons";
|
import { BellIcon } from "./icons";
|
||||||
import NotificationList from "./notification-list";
|
import NotificationList from "./notification-list";
|
||||||
|
|
||||||
export function Notification() {
|
export function Notification() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isDotVisible, setIsDotVisible] = useState(true);
|
const [isBadgeVisible, setIsBadgeVisible] = useState(true);
|
||||||
const [unreadNotificationCount, setUnreadNotificationCount] =
|
const [unreadNotificationCount, setUnreadNotificationCount] =
|
||||||
useState<number>(0);
|
useState<number>(0);
|
||||||
|
const previousUnreadCountRef = useRef(0);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [currentNotification, setCurrentNotification] =
|
const [currentNotification, setCurrentNotification] =
|
||||||
useState<TNotificationDetailsResponse | null>(null);
|
useState<TNotificationDetailsResponse | null>(null);
|
||||||
|
|
||||||
const { mutate } = useMarkNotificationAsSeen(
|
const { mutate } = useMarkNotificationAsSeen();
|
||||||
currentNotification?.id ?? "",
|
const { data: unreadNotifications, isLoading: isLoadingNotifications } =
|
||||||
() => setIsModalOpen(false),
|
useGetMyNotifications({ seen: false });
|
||||||
);
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const { user } = useUser();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const count = unreadNotifications?.data.length ?? 0;
|
||||||
|
setUnreadNotificationCount(count);
|
||||||
|
|
||||||
|
if (count > previousUnreadCountRef.current) {
|
||||||
|
setIsBadgeVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
previousUnreadCountRef.current = count;
|
||||||
|
}, [unreadNotifications]);
|
||||||
|
|
||||||
|
const notificationHistoryHref = useMemo(() => {
|
||||||
|
if (!user?.id) return "/notification-history";
|
||||||
|
|
||||||
|
return `/notification-history?user_id=${encodeURIComponent(user.id)}`;
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
const handleNotificationClick = (
|
const handleNotificationClick = (
|
||||||
notification: TNotificationDetailsResponse,
|
notification: TNotificationDetailsResponse,
|
||||||
@@ -36,7 +60,7 @@ export function Notification() {
|
|||||||
setCurrentNotification(notification);
|
setCurrentNotification(notification);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
mutate();
|
if (notification.id) mutate(notification.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -45,22 +69,24 @@ export function Notification() {
|
|||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
setIsOpen={(open) => {
|
setIsOpen={(open) => {
|
||||||
setIsOpen(open);
|
setIsOpen(open);
|
||||||
if (setIsDotVisible) setIsDotVisible(false);
|
if (open) setIsBadgeVisible(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownTrigger
|
<DropdownTrigger
|
||||||
className="grid size-12 place-items-center rounded-full border border-stroke/70 bg-white/75 text-dark shadow-theme-xs backdrop-blur-md outline-none transition-colors hover:text-primary focus-visible:border-primary focus-visible:text-primary dark:border-dark-3 dark:bg-dark-2/75 dark:text-white dark:focus-visible:border-primary"
|
className="shadow-theme-xs grid size-12 place-items-center rounded-full border border-stroke/70 bg-white/75 text-dark outline-none backdrop-blur-md transition-colors hover:text-primary focus-visible:border-primary focus-visible:text-primary dark:border-dark-3 dark:bg-dark-2/75 dark:text-white dark:focus-visible:border-primary"
|
||||||
aria-label="View Notifications"
|
aria-label="View Notifications"
|
||||||
>
|
>
|
||||||
<span className="relative">
|
<span className="relative">
|
||||||
<BellIcon />
|
<BellIcon />
|
||||||
{isDotVisible && (
|
{isBadgeVisible && unreadNotificationCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-red-light absolute right-0 top-0 z-1 size-2 rounded-full ring-2 ring-gray-2 dark:ring-dark-3",
|
"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",
|
||||||
|
unreadNotificationCount > 9 ? "h-[18px]" : "size-[18px]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="bg-red-light absolute inset-0 -z-1 animate-ping rounded-full opacity-75" />
|
<span className="absolute inset-0 -z-1 animate-ping rounded-full bg-rose-500/70" />
|
||||||
|
{unreadNotificationCount > 99 ? "99+" : unreadNotificationCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -68,24 +94,25 @@ export function Notification() {
|
|||||||
|
|
||||||
<DropdownContent
|
<DropdownContent
|
||||||
align={isMobile ? "end" : "center"}
|
align={isMobile ? "end" : "center"}
|
||||||
className="w-[15rem] max-w-[calc(100vw-1rem)] min-[360px]:w-[16rem] min-[390px]:w-[17rem] min-[430px]:w-[18rem] rounded-2xl border border-stroke/70 bg-white/95 px-2.5 py-3 shadow-theme-xs backdrop-blur-md sm:w-[22rem] sm:px-3.5 dark:border-dark-3 dark:bg-gray-dark/95"
|
className="shadow-theme-xs w-[15rem] max-w-[calc(100vw-1rem)] rounded-2xl border border-stroke/70 bg-white/95 px-2.5 py-3 backdrop-blur-md dark:border-dark-3 dark:bg-gray-dark/95 min-[360px]:w-[16rem] min-[390px]:w-[17rem] min-[430px]:w-[18rem] sm:w-[22rem] sm:px-3.5"
|
||||||
>
|
>
|
||||||
<div className="mb-1 flex flex-wrap items-center justify-between gap-2 px-1 py-1.5 sm:px-2">
|
<div className="mb-1 flex flex-wrap items-center justify-between gap-2 px-1 py-1.5 sm:px-2">
|
||||||
<span className="text-base font-semibold text-dark sm:text-lg dark:text-white">
|
<span className="text-base font-semibold text-dark dark:text-white sm:text-lg">
|
||||||
Notifications
|
Notifications
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 rounded-full border border-white/35 bg-gradient-to-br from-primary/85 to-primary/70 px-[9px] py-0.5 text-xs font-semibold text-white shadow-theme-xs dark:border-white/20">
|
<span className="shadow-theme-xs shrink-0 rounded-full border border-white/35 bg-gradient-to-br from-primary/85 to-primary/70 px-[9px] py-0.5 text-xs font-semibold text-white dark:border-white/20">
|
||||||
{unreadNotificationCount} new
|
{unreadNotificationCount} new
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NotificationList
|
<NotificationList
|
||||||
setUnreadNotificationCount={setUnreadNotificationCount}
|
notifications={unreadNotifications?.data}
|
||||||
|
isLoading={isLoadingNotifications}
|
||||||
onNotificationClick={handleNotificationClick}
|
onNotificationClick={handleNotificationClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/notification-history"
|
href={notificationHistoryHref}
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
className="block rounded-xl border border-primary/40 bg-white/70 p-2 text-center text-sm font-semibold tracking-wide text-primary outline-none transition-colors hover:bg-primary/10 focus:bg-primary/10 focus:text-primary focus-visible:border-primary dark:border-dark-3 dark:bg-dark-2/70 dark:text-dark-6 dark:hover:border-dark-5 dark:hover:bg-dark-3 dark:hover:text-dark-7 dark:focus-visible:border-dark-5 dark:focus-visible:bg-dark-3 dark:focus-visible:text-dark-7"
|
className="block rounded-xl border border-primary/40 bg-white/70 p-2 text-center text-sm font-semibold tracking-wide text-primary outline-none transition-colors hover:bg-primary/10 focus:bg-primary/10 focus:text-primary focus-visible:border-primary dark:border-dark-3 dark:bg-dark-2/70 dark:text-dark-6 dark:hover:border-dark-5 dark:hover:bg-dark-3 dark:hover:text-dark-7 dark:focus-visible:border-dark-5 dark:focus-visible:bg-dark-3 dark:focus-visible:text-dark-7"
|
||||||
>
|
>
|
||||||
@@ -101,10 +128,86 @@ export function Notification() {
|
|||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
}}
|
}}
|
||||||
confirmText="Mark as read"
|
confirmText="Mark as read"
|
||||||
|
classNames="relative !p-0 w-[34rem] max-w-[calc(100vw-2rem)] !rounded-3xl border border-stroke/60 dark:border-dark-3 [&>button]:absolute [&>button]:right-3 [&>button]:top-3 [&>button]:z-10 [&>button]:!mb-0 [&>button]:!text-white/80 [&>button]:hover:!text-white"
|
||||||
>
|
>
|
||||||
<div className="flex max-w-prose flex-col gap-3">
|
<div className="flex max-w-full flex-col">
|
||||||
<h2 className="font-lg font-bold">{currentNotification?.title}</h2>
|
{/* Gradient header */}
|
||||||
<p>{currentNotification?.message}</p>
|
<div className="relative overflow-hidden bg-gradient-to-br from-primary via-primary to-blue-700 px-6 pb-6 pt-7">
|
||||||
|
<div className="pointer-events-none absolute -right-8 -top-10 size-32 rounded-full bg-white/10 blur-md" />
|
||||||
|
<div className="pointer-events-none absolute -bottom-12 -left-6 size-28 rounded-full bg-white/10 blur-md" />
|
||||||
|
|
||||||
|
<div className="relative flex items-start gap-4">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="mb-1.5 flex flex-wrap items-center gap-2">
|
||||||
|
{currentNotification?.priority && (
|
||||||
|
<Status status={currentNotification.priority}>
|
||||||
|
{capitalize(currentNotification.priority)}
|
||||||
|
</Status>
|
||||||
|
)}
|
||||||
|
{currentNotification?.type && (
|
||||||
|
<span className="rounded-full bg-white/20 px-2.5 py-0.5 text-xs font-medium text-white ring-1 ring-white/25">
|
||||||
|
{capitalize(currentNotification.type)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-bold leading-snug text-white">
|
||||||
|
{currentNotification?.title || "Notification"}
|
||||||
|
</h2>
|
||||||
|
{!!currentNotification?.created_at && (
|
||||||
|
<p className="mt-1 text-xs font-medium text-white/75">
|
||||||
|
{unixToDate({
|
||||||
|
unix: currentNotification.created_at,
|
||||||
|
hasTime: true,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex flex-col gap-4 px-6 py-5">
|
||||||
|
{currentNotification?.image && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={currentNotification.image}
|
||||||
|
alt={currentNotification.title || "Notification image"}
|
||||||
|
className="max-h-52 w-full rounded-2xl object-cover ring-1 ring-stroke/60 dark:ring-dark-3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="text-sm leading-relaxed text-dark-4 dark:text-dark-6 [&_a]:text-primary [&_a]:underline"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: sanitizeHtmlWithStyles(
|
||||||
|
currentNotification?.message || "No message content.",
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-1 flex items-center justify-end gap-3 border-t border-stroke/60 pt-4 dark:border-dark-3">
|
||||||
|
{currentNotification?.link && (
|
||||||
|
<Link
|
||||||
|
href={currentNotification.link}
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="rounded-xl border border-primary/40 bg-primary/5 px-4 py-2 text-sm font-semibold text-primary transition-colors hover:bg-primary/10"
|
||||||
|
>
|
||||||
|
Open link
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,36 +2,29 @@
|
|||||||
|
|
||||||
import Loading from "@/components/loading";
|
import Loading from "@/components/loading";
|
||||||
import IsVisible from "@/components/ui/IsVisible";
|
import IsVisible from "@/components/ui/IsVisible";
|
||||||
import { useGetMyNotifications } from "@/hooks/queries/useNotificationQueries";
|
|
||||||
import { TNotificationDetailsResponse } from "@/lib/api/types/NotificationHistory";
|
import { TNotificationDetailsResponse } from "@/lib/api/types/NotificationHistory";
|
||||||
import { truncateString } from "@/utils/shared";
|
import { truncateString } from "@/utils/shared";
|
||||||
import { Dispatch, SetStateAction, useEffect } from "react";
|
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
setUnreadNotificationCount: Dispatch<SetStateAction<number>>;
|
notifications?: TNotificationDetailsResponse[];
|
||||||
|
isLoading: boolean;
|
||||||
onNotificationClick: (notification: TNotificationDetailsResponse) => void;
|
onNotificationClick: (notification: TNotificationDetailsResponse) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotificationList = ({
|
const NotificationList = ({
|
||||||
setUnreadNotificationCount,
|
notifications,
|
||||||
|
isLoading,
|
||||||
onNotificationClick,
|
onNotificationClick,
|
||||||
}: IProps) => {
|
}: IProps) => {
|
||||||
const { data, isLoading } = useGetMyNotifications({ seen: false });
|
|
||||||
useEffect(() => {
|
|
||||||
setUnreadNotificationCount(data?.data.length ?? 0);
|
|
||||||
}, [data, isLoading]);
|
|
||||||
|
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return <Loading className="min-h-48 flex-none justify-center p-6" />;
|
||||||
<Loading className="min-h-48 flex-none justify-center p-6" />
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="mb-3 max-h-[23rem] space-y-1.5 overflow-y-auto">
|
<ul className="mb-3 max-h-[23rem] space-y-1.5 overflow-y-auto">
|
||||||
<IsVisible condition={data?.data.length === 0}>
|
<IsVisible condition={notifications?.length === 0}>
|
||||||
<h2 className="cursor-default ps-2">No new notification</h2>
|
<h2 className="cursor-default ps-2">No new notification</h2>
|
||||||
</IsVisible>
|
</IsVisible>
|
||||||
{data?.data.map((notification) => (
|
{notifications?.map((notification) => (
|
||||||
<li
|
<li
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
|
|||||||
@@ -218,8 +218,15 @@ export const useAdminsPresenter = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
|
filterFormReset({
|
||||||
|
...apiDefaultParams,
|
||||||
|
offset: 0,
|
||||||
|
limit: 10,
|
||||||
|
search: "",
|
||||||
|
status: "",
|
||||||
|
});
|
||||||
router.push(pathName);
|
router.push(pathName);
|
||||||
}, [pathName, router]);
|
}, [filterFormReset, pathName, router]);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
"row",
|
"row",
|
||||||
|
|||||||
@@ -61,8 +61,9 @@ const useCmsTablePresenter = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
|
filterFormReset({ q: "", key: "" });
|
||||||
router.push(pathname);
|
router.push(pathname);
|
||||||
}, [pathname, router]);
|
}, [pathname, router, filterFormReset]);
|
||||||
|
|
||||||
const onConfirmDelete = () => {
|
const onConfirmDelete = () => {
|
||||||
if (!currentCms?.id) return;
|
if (!currentCms?.id) return;
|
||||||
|
|||||||
@@ -94,8 +94,9 @@ const useCompanyCategoriesPresenter = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
|
filterFormReset({ q: "", published: undefined });
|
||||||
router.push(pathName);
|
router.push(pathName);
|
||||||
}, [pathName, router]);
|
}, [pathName, router, filterFormReset]);
|
||||||
|
|
||||||
const onConfirmDelete = () => {
|
const onConfirmDelete = () => {
|
||||||
if (!currentCategory) return;
|
if (!currentCategory) return;
|
||||||
|
|||||||
@@ -100,8 +100,15 @@ export const useCompanyListPresenter = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
|
filterFormReset({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
currency: "",
|
||||||
|
employee_count: "",
|
||||||
|
});
|
||||||
router.push(pathName);
|
router.push(pathName);
|
||||||
}, [pathName, router]);
|
}, [pathName, router, filterFormReset]);
|
||||||
|
|
||||||
const onConfirmDelete = () => {
|
const onConfirmDelete = () => {
|
||||||
if (currentCompany) {
|
if (currentCompany) {
|
||||||
|
|||||||
@@ -90,8 +90,9 @@ export const useContactUsListPresenter = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
|
filterFormReset({ q: "", status: "" });
|
||||||
router.push(pathname);
|
router.push(pathname);
|
||||||
}, [pathname, router]);
|
}, [pathname, router, filterFormReset]);
|
||||||
|
|
||||||
const onConfirmDelete = () => {
|
const onConfirmDelete = () => {
|
||||||
if (!currentContact) return;
|
if (!currentContact) return;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import InputGroup from "@/components/FormElements/InputGroup";
|
||||||
import { Select } from "@/components/FormElements/select";
|
import { Select } from "@/components/FormElements/select";
|
||||||
import { Button } from "@/components/ui-elements/button";
|
import { Button } from "@/components/ui-elements/button";
|
||||||
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||||
import { AdminRoles, CustomerStatus, CustomerType } from "@/constants/enums";
|
import { AdminRoles, CustomerStatus, CustomerType } from "@/constants/enums";
|
||||||
|
import { ILabelValue } from "@/types/shared";
|
||||||
import { getEnumAsArray } from "@/utils/shared";
|
import { getEnumAsArray } from "@/utils/shared";
|
||||||
import {
|
import {
|
||||||
FieldValues,
|
FieldValues,
|
||||||
@@ -22,11 +24,25 @@ interface CustomerListFiltersProps {
|
|||||||
watch: UseFormWatch<FieldValues>;
|
watch: UseFormWatch<FieldValues>;
|
||||||
setValue: UseFormSetValue<any>;
|
setValue: UseFormSetValue<any>;
|
||||||
onClearAll: () => void;
|
onClearAll: () => void;
|
||||||
|
/** Lazy-loaded company options for the company dropdown. */
|
||||||
|
companyItems: ILabelValue[];
|
||||||
|
onCompanySearch: (query: string) => void;
|
||||||
|
onCompanyLoadMore: () => void;
|
||||||
|
companyHasMore?: boolean;
|
||||||
|
companyIsLoading: boolean;
|
||||||
|
companyIsLoadingMore: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function countActive(values: Record<string, unknown>): number {
|
function countActive(values: Record<string, unknown>): number {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const key of ["status", "role", "type"] as const) {
|
for (const key of [
|
||||||
|
"status",
|
||||||
|
"role",
|
||||||
|
"type",
|
||||||
|
"full_name",
|
||||||
|
"email",
|
||||||
|
"company_id",
|
||||||
|
] as const) {
|
||||||
const v = values[key];
|
const v = values[key];
|
||||||
if (v !== undefined && v !== null && v !== "" && String(v).trim()) n++;
|
if (v !== undefined && v !== null && v !== "" && String(v).trim()) n++;
|
||||||
}
|
}
|
||||||
@@ -41,6 +57,12 @@ const CustomerListFilters = ({
|
|||||||
watch,
|
watch,
|
||||||
setValue,
|
setValue,
|
||||||
onClearAll,
|
onClearAll,
|
||||||
|
companyItems,
|
||||||
|
onCompanySearch,
|
||||||
|
onCompanyLoadMore,
|
||||||
|
companyHasMore,
|
||||||
|
companyIsLoading,
|
||||||
|
companyIsLoadingMore,
|
||||||
}: CustomerListFiltersProps) => {
|
}: CustomerListFiltersProps) => {
|
||||||
const watched = watch();
|
const watched = watch();
|
||||||
const activeCount = useMemo(
|
const activeCount = useMemo(
|
||||||
@@ -63,6 +85,36 @@ const CustomerListFilters = ({
|
|||||||
data-cy="customers-filter-form"
|
data-cy="customers-filter-form"
|
||||||
>
|
>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<InputGroup
|
||||||
|
{...filterRegister("full_name")}
|
||||||
|
name="full_name"
|
||||||
|
label="Full name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by full name"
|
||||||
|
/>
|
||||||
|
<InputGroup
|
||||||
|
{...filterRegister("email")}
|
||||||
|
name="email"
|
||||||
|
label="Email"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by email"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
{...filterRegister("company_id")}
|
||||||
|
items={companyItems}
|
||||||
|
label="Company"
|
||||||
|
name="company_id"
|
||||||
|
placeholder="Select company"
|
||||||
|
clearable
|
||||||
|
value={watch("company_id")}
|
||||||
|
onClear={() => setValue("company_id", undefined)}
|
||||||
|
searchPlaceholder="Search companies…"
|
||||||
|
onSearch={onCompanySearch}
|
||||||
|
onLoadMore={onCompanyLoadMore}
|
||||||
|
hasMore={companyHasMore}
|
||||||
|
isLoading={companyIsLoading}
|
||||||
|
isLoadingMore={companyIsLoadingMore}
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
{...filterRegister("status")}
|
{...filterRegister("status")}
|
||||||
items={getEnumAsArray(CustomerStatus)}
|
items={getEnumAsArray(CustomerStatus)}
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ const CustomersTable = () => {
|
|||||||
openResetPasswordModalForCustomer,
|
openResetPasswordModalForCustomer,
|
||||||
generatedPassword,
|
generatedPassword,
|
||||||
isResettingPassword,
|
isResettingPassword,
|
||||||
|
companyItems,
|
||||||
|
onCompanySearch,
|
||||||
|
onCompanyLoadMore,
|
||||||
|
companyHasMore,
|
||||||
|
companyIsLoading,
|
||||||
|
companyIsLoadingMore,
|
||||||
} = useCustomerListPresenter();
|
} = useCustomerListPresenter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -88,6 +94,12 @@ const CustomersTable = () => {
|
|||||||
search={search}
|
search={search}
|
||||||
watch={watch}
|
watch={watch}
|
||||||
onClearAll={clearAllFilters}
|
onClearAll={clearAllFilters}
|
||||||
|
companyItems={companyItems}
|
||||||
|
onCompanySearch={onCompanySearch}
|
||||||
|
onCompanyLoadMore={onCompanyLoadMore}
|
||||||
|
companyHasMore={companyHasMore}
|
||||||
|
companyIsLoading={companyIsLoading}
|
||||||
|
companyIsLoadingMore={companyIsLoadingMore}
|
||||||
/>
|
/>
|
||||||
<div ref={tableSectionRef}>
|
<div ref={tableSectionRef}>
|
||||||
<TableSortProvider
|
<TableSortProvider
|
||||||
@@ -206,7 +218,6 @@ const CustomersTable = () => {
|
|||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
|
|
||||||
<span className="sr-only">Delete Company </span>
|
<span className="sr-only">Delete Company </span>
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -219,7 +230,6 @@ const CustomersTable = () => {
|
|||||||
router.push(`${pathName}/edit/${customer.id}`)
|
router.push(`${pathName}/edit/${customer.id}`)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
|
|
||||||
<span className="sr-only">Edit Company </span>
|
<span className="sr-only">Edit Company </span>
|
||||||
<PencilSquareIcon />
|
<PencilSquareIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -237,9 +247,6 @@ const CustomersTable = () => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip
|
|
||||||
{..._TooltipDefaultParams({ id: "assign-company" })}
|
|
||||||
/>
|
|
||||||
<span className="sr-only">Assign To Company </span>
|
<span className="sr-only">Assign To Company </span>
|
||||||
<Building />
|
<Building />
|
||||||
</button>
|
</button>
|
||||||
@@ -253,11 +260,6 @@ const CustomersTable = () => {
|
|||||||
openResetPasswordModalForCustomer(customer)
|
openResetPasswordModalForCustomer(customer)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tooltip
|
|
||||||
{..._TooltipDefaultParams({
|
|
||||||
id: "reset-password",
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
Reset Customer Password
|
Reset Customer Password
|
||||||
</span>
|
</span>
|
||||||
@@ -272,7 +274,6 @@ const CustomersTable = () => {
|
|||||||
router.push(`${pathName}/feedback/${customer.id}`);
|
router.push(`${pathName}/feedback/${customer.id}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
|
|
||||||
<ExclamationIcon />
|
<ExclamationIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,6 +347,11 @@ const CustomersTable = () => {
|
|||||||
onCancel={() => setIsModalOpen(false)}
|
onCancel={() => setIsModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
|
||||||
|
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
|
||||||
|
<Tooltip {..._TooltipDefaultParams({ id: "assign-company" })} />
|
||||||
|
<Tooltip {..._TooltipDefaultParams({ id: "reset-password" })} />
|
||||||
|
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
|
||||||
</ListWrapper>
|
</ListWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
import { SortDirection } from "@/components/ui/tableSortContext";
|
import { SortDirection } from "@/components/ui/tableSortContext";
|
||||||
import {
|
import {
|
||||||
useAssignCompany,
|
useAssignCompany,
|
||||||
|
useCompaniesInfiniteQuery,
|
||||||
useCompanyFullList,
|
useCompanyFullList,
|
||||||
useCustomersQuery,
|
useCustomersQuery,
|
||||||
useDeleteCustomerQuery,
|
useDeleteCustomerQuery,
|
||||||
useResetCustomerPassword,
|
useResetCustomerPassword,
|
||||||
} from "@/hooks/queries";
|
} from "@/hooks/queries";
|
||||||
|
import useDebounce from "@/hooks/useDebounce";
|
||||||
import { useHybridPagination } from "@/hooks/useHybridPagination";
|
import { useHybridPagination } from "@/hooks/useHybridPagination";
|
||||||
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||||
import { TAssignCustomerToCompanyCredentials, TCustomer } from "@/lib/api";
|
import { TAssignCustomerToCompanyCredentials, TCustomer } from "@/lib/api";
|
||||||
@@ -50,6 +52,35 @@ const useCustomerListPresenter = () => {
|
|||||||
setValue: setFilterValue,
|
setValue: setFilterValue,
|
||||||
} = useForm();
|
} = useForm();
|
||||||
|
|
||||||
|
// Lazy-loaded company options for the inline filter dropdown.
|
||||||
|
const [companySearch, setCompanySearch] = useState("");
|
||||||
|
const debouncedCompanySearch = useDebounce(companySearch, 400);
|
||||||
|
const companiesQuery = useCompaniesInfiniteQuery(
|
||||||
|
debouncedCompanySearch ? { search: debouncedCompanySearch } : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const companyItems = useMemo(
|
||||||
|
() =>
|
||||||
|
companiesQuery.data?.pages.flatMap(
|
||||||
|
(page) =>
|
||||||
|
page.data.companies?.map((company) => ({
|
||||||
|
label: company.name,
|
||||||
|
value: company.id,
|
||||||
|
})) ?? [],
|
||||||
|
) ?? [],
|
||||||
|
[companiesQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onCompanySearch = useCallback((query: string) => {
|
||||||
|
setCompanySearch(query);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onCompanyLoadMore = useCallback(() => {
|
||||||
|
if (companiesQuery.hasNextPage && !companiesQuery.isFetchingNextPage) {
|
||||||
|
companiesQuery.fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [companiesQuery]);
|
||||||
|
|
||||||
const tableSectionRef = useRef<HTMLDivElement>(null);
|
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||||
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
@@ -125,8 +156,27 @@ const useCustomerListPresenter = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
|
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);
|
router.push(pathName);
|
||||||
}, [pathName, router]);
|
}, [pathName, router, filterFormReset, setFilterValue]);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
"row",
|
"row",
|
||||||
@@ -227,6 +277,13 @@ const useCustomerListPresenter = () => {
|
|||||||
openResetPasswordModalForCustomer,
|
openResetPasswordModalForCustomer,
|
||||||
generatedPassword,
|
generatedPassword,
|
||||||
isResettingPassword,
|
isResettingPassword,
|
||||||
|
companyItems,
|
||||||
|
onCompanySearch,
|
||||||
|
onCompanyLoadMore,
|
||||||
|
companyHasMore: companiesQuery.hasNextPage,
|
||||||
|
companyIsLoading:
|
||||||
|
companiesQuery.isFetching && !companiesQuery.isFetchingNextPage,
|
||||||
|
companyIsLoadingMore: companiesQuery.isFetchingNextPage,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -90,8 +90,9 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
|
filterFormReset({ q: "" });
|
||||||
router.push(pathName);
|
router.push(pathName);
|
||||||
}, [pathName, router]);
|
}, [pathName, router, filterFormReset]);
|
||||||
|
|
||||||
const getShowcaseSectionTitle = () => {
|
const getShowcaseSectionTitle = () => {
|
||||||
if (paramKey === "tender_id") {
|
if (paramKey === "tender_id") {
|
||||||
|
|||||||
@@ -11,14 +11,12 @@ interface ChangeStatusFormProps {
|
|||||||
onSubmit: (data: TChangeInquiryStatusCredentials) => void;
|
onSubmit: (data: TChangeInquiryStatusCredentials) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
currentStatus: string;
|
currentStatus: string;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChangeStatusForm = ({
|
const ChangeStatusForm = ({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
currentStatus,
|
currentStatus,
|
||||||
|
|
||||||
}: ChangeStatusFormProps) => {
|
}: ChangeStatusFormProps) => {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -37,10 +35,19 @@ const ChangeStatusForm = ({
|
|||||||
value: status,
|
value: status,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const handleStatusSubmit = (data: TChangeInquiryStatusCredentials) => {
|
||||||
|
if (data.status === currentStatus) return;
|
||||||
|
onSubmit(data);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={handleSubmit(handleStatusSubmit)} className="space-y-4">
|
||||||
<Select
|
<Select
|
||||||
{...register("status", { required: "Status is required" })}
|
{...register("status", {
|
||||||
|
required: "Status is required",
|
||||||
|
validate: (value) =>
|
||||||
|
value !== currentStatus || "Select a different status",
|
||||||
|
})}
|
||||||
name="status"
|
name="status"
|
||||||
label="Status"
|
label="Status"
|
||||||
items={statusOptions}
|
items={statusOptions}
|
||||||
|
|||||||
@@ -88,8 +88,9 @@ const useInquiriesListPresenter = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
|
filterFormReset({ q: "", status: "" });
|
||||||
router.push(pathname);
|
router.push(pathname);
|
||||||
}, [pathname, router]);
|
}, [pathname, router, filterFormReset]);
|
||||||
|
|
||||||
const deleteInquiry = () => {
|
const deleteInquiry = () => {
|
||||||
if (!currentInquiry?.id) return;
|
if (!currentInquiry?.id) return;
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import InputGroup from "@/components/FormElements/InputGroup";
|
|||||||
import { Select } from "@/components/FormElements/select";
|
import { Select } from "@/components/FormElements/select";
|
||||||
import { Button } from "@/components/ui-elements/button";
|
import { Button } from "@/components/ui-elements/button";
|
||||||
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||||
|
import { useUser } from "@/contexts";
|
||||||
|
import { apiDefaultParams } from "@/constants/Api_Params";
|
||||||
|
import { userService } from "@/lib/api/services/user-services";
|
||||||
|
import { ILabelValue } from "@/types/shared";
|
||||||
import {
|
import {
|
||||||
FieldValues,
|
FieldValues,
|
||||||
UseFormHandleSubmit,
|
UseFormHandleSubmit,
|
||||||
@@ -34,11 +38,38 @@ function countActive(values: Record<string, unknown>): number {
|
|||||||
) {
|
) {
|
||||||
n++;
|
n++;
|
||||||
}
|
}
|
||||||
|
const userId = values.user_id;
|
||||||
|
if (
|
||||||
|
userId !== undefined &&
|
||||||
|
userId !== null &&
|
||||||
|
userId !== "" &&
|
||||||
|
String(userId).trim()
|
||||||
|
) {
|
||||||
|
n++;
|
||||||
|
}
|
||||||
const searchVal = values.search;
|
const searchVal = values.search;
|
||||||
if (typeof searchVal === "string" && searchVal.trim()) n++;
|
if (typeof searchVal === "string" && searchVal.trim()) n++;
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchUsers = async (query: string): Promise<ILabelValue[]> => {
|
||||||
|
const response = await userService.adminsList({
|
||||||
|
...apiDefaultParams,
|
||||||
|
search: query,
|
||||||
|
offset: 0,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (response.data?.users ?? []).map((user) => ({
|
||||||
|
value: user.id,
|
||||||
|
label:
|
||||||
|
user.full_name ||
|
||||||
|
user.username ||
|
||||||
|
user.email ||
|
||||||
|
user.id,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const NotificationHistoryListFilters = ({
|
const NotificationHistoryListFilters = ({
|
||||||
filterRegister,
|
filterRegister,
|
||||||
isMutating,
|
isMutating,
|
||||||
@@ -48,7 +79,22 @@ const NotificationHistoryListFilters = ({
|
|||||||
setValue,
|
setValue,
|
||||||
onClearAll,
|
onClearAll,
|
||||||
}: NotificationHistoryListFiltersProps) => {
|
}: NotificationHistoryListFiltersProps) => {
|
||||||
|
const { user } = useUser();
|
||||||
const watched = watch();
|
const watched = watch();
|
||||||
|
const currentUserOption = useMemo<ILabelValue[]>(() => {
|
||||||
|
if (!user?.id) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: user.id,
|
||||||
|
label:
|
||||||
|
user.full_name ||
|
||||||
|
user.username ||
|
||||||
|
user.email ||
|
||||||
|
user.id,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [user?.email, user?.full_name, user?.id, user?.username]);
|
||||||
const activeCount = useMemo(
|
const activeCount = useMemo(
|
||||||
() => countActive(watched as Record<string, unknown>),
|
() => countActive(watched as Record<string, unknown>),
|
||||||
[watched],
|
[watched],
|
||||||
@@ -68,7 +114,7 @@ const NotificationHistoryListFilters = ({
|
|||||||
onSubmit={handleFilterSubmit(search)}
|
onSubmit={handleFilterSubmit(search)}
|
||||||
data-cy="notification-history-filter-form"
|
data-cy="notification-history-filter-form"
|
||||||
>
|
>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
<Select
|
<Select
|
||||||
{...filterRegister("seen")}
|
{...filterRegister("seen")}
|
||||||
items={[
|
items={[
|
||||||
@@ -82,6 +128,22 @@ const NotificationHistoryListFilters = ({
|
|||||||
value={watch("seen")}
|
value={watch("seen")}
|
||||||
onClear={() => setValue("seen", undefined)}
|
onClear={() => setValue("seen", undefined)}
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
{...filterRegister("user_id")}
|
||||||
|
items={currentUserOption}
|
||||||
|
label="User"
|
||||||
|
name="user_id"
|
||||||
|
placeholder="Current user"
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
searchMode="server"
|
||||||
|
searchPlaceholder="Search users"
|
||||||
|
searchQueryKey={["notification-history-user-filter"]}
|
||||||
|
searchFn={searchUsers}
|
||||||
|
value={watch("user_id")}
|
||||||
|
onClear={() => setValue("user_id", undefined)}
|
||||||
|
dataCy="notification-history-filter-user-select"
|
||||||
|
/>
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("search")}
|
{...filterRegister("search")}
|
||||||
name="search"
|
name="search"
|
||||||
|
|||||||
@@ -83,8 +83,9 @@ const useNotificationHistoryTablePresenter = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
|
filterFormReset({ seen: undefined, search: "", user_id: undefined });
|
||||||
router.push(pathName);
|
router.push(pathName);
|
||||||
}, [pathName, router]);
|
}, [pathName, router, filterFormReset]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notificationHistory: data?.data,
|
notificationHistory: data?.data,
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import { Button } from "@/components/ui-elements/button";
|
|||||||
import CollapsibleFilterSection from "@/components/ui/CollapsibleFilterSection";
|
import CollapsibleFilterSection from "@/components/ui/CollapsibleFilterSection";
|
||||||
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||||
import { Countries } from "@/constants/countries";
|
import { Countries } from "@/constants/countries";
|
||||||
|
import { isDateRangeActive } from "@/utils/shared";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Control,
|
Control,
|
||||||
|
Controller,
|
||||||
FieldValues,
|
FieldValues,
|
||||||
UseFormHandleSubmit,
|
UseFormHandleSubmit,
|
||||||
UseFormRegister,
|
UseFormRegister,
|
||||||
@@ -37,15 +39,6 @@ const RANGE_KEYS = [
|
|||||||
"submission_deadline_range",
|
"submission_deadline_range",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function isRangeActive(value: unknown): boolean {
|
|
||||||
if (!Array.isArray(value) || value.length === 0) return false;
|
|
||||||
return value.some((x) => {
|
|
||||||
if (x === undefined || x === null || x === "") return false;
|
|
||||||
const n = typeof x === "number" ? x : Number(x);
|
|
||||||
return Number.isFinite(n) && n > 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function countActiveFilters(values: Record<string, unknown>): number {
|
function countActiveFilters(values: Record<string, unknown>): number {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
const textKeys = [
|
const textKeys = [
|
||||||
@@ -66,7 +59,7 @@ function countActiveFilters(values: Record<string, unknown>): number {
|
|||||||
const country = values.country;
|
const country = values.country;
|
||||||
if (typeof country === "string" && country.trim()) n++;
|
if (typeof country === "string" && country.trim()) n++;
|
||||||
for (const key of RANGE_KEYS) {
|
for (const key of RANGE_KEYS) {
|
||||||
if (isRangeActive(values[key])) n++;
|
if (isDateRangeActive(values[key])) n++;
|
||||||
}
|
}
|
||||||
if (values.documents_scraped === true) n++;
|
if (values.documents_scraped === true) n++;
|
||||||
return n;
|
return n;
|
||||||
@@ -222,12 +215,22 @@ const TenderListFilters = ({
|
|||||||
subtitle="Restrict to tenders whose documents have been scraped."
|
subtitle="Restrict to tenders whose documents have been scraped."
|
||||||
>
|
>
|
||||||
<div className="max-w-fit rounded-xl border border-stroke/40 bg-white/60 p-4 dark:border-white/[0.06] dark:bg-dark-3/40">
|
<div className="max-w-fit rounded-xl border border-stroke/40 bg-white/60 p-4 dark:border-white/[0.06] dark:bg-dark-3/40">
|
||||||
|
<Controller
|
||||||
|
name="documents_scraped"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange, name, onBlur, ref } }) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
ref={ref}
|
||||||
|
name={name}
|
||||||
label="Documents scraped"
|
label="Documents scraped"
|
||||||
minimal
|
minimal
|
||||||
withBg
|
withBg
|
||||||
withIcon="check"
|
withIcon="check"
|
||||||
{...filterRegister("documents_scraped")}
|
checked={value === true}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onChange={(event) => onChange(event.target.checked)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleFilterSection>
|
</CollapsibleFilterSection>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { ExclamationIcon, EyeIcon } from "@/assets/icons";
|
import { ExclamationIcon, EyeIcon } from "@/assets/icons";
|
||||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
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 Status from "@/components/ui/Status";
|
import Status from "@/components/ui/Status";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -12,13 +15,10 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
TableSortProvider,
|
TableSortProvider,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
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 TableSkeleton from "@/components/ui/TableSkeleton";
|
import TableSkeleton from "@/components/ui/TableSkeleton";
|
||||||
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { truncateString } from "@/utils/shared";
|
||||||
import { Tooltip } from "react-tooltip";
|
import { Tooltip } from "react-tooltip";
|
||||||
import TenderListFilters from "./TenderListFilters";
|
import TenderListFilters from "./TenderListFilters";
|
||||||
import useTenderListPresenter from "./useTenderListPresenter";
|
import useTenderListPresenter from "./useTenderListPresenter";
|
||||||
@@ -48,7 +48,8 @@ const TendersTable = () => {
|
|||||||
navigateToTenderDetails,
|
navigateToTenderDetails,
|
||||||
navigateToTenderFeedback,
|
navigateToTenderFeedback,
|
||||||
handlePaginationChange,
|
handlePaginationChange,
|
||||||
buildWindowedPageLabel,
|
hasNext,
|
||||||
|
hasPrevious,
|
||||||
getRowNumber,
|
getRowNumber,
|
||||||
formatDateTimeCell,
|
formatDateTimeCell,
|
||||||
pagination,
|
pagination,
|
||||||
@@ -117,7 +118,18 @@ const TendersTable = () => {
|
|||||||
{getRowNumber(index)}
|
{getRowNumber(index)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-start" colSpan={100}>
|
<TableCell className="text-start" colSpan={100}>
|
||||||
{item.title}
|
{item.title.length > 40 ? (
|
||||||
|
<span
|
||||||
|
data-tooltip-id="tender-title"
|
||||||
|
data-tooltip-content={item.title}
|
||||||
|
data-tooltip-place="top"
|
||||||
|
className="cursor-help underline decoration-dotted decoration-1 underline-offset-4"
|
||||||
|
>
|
||||||
|
{truncateString(item.title, 20, 20)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
item.title
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-start" colSpan={100}>
|
<TableCell className="text-start" colSpan={100}>
|
||||||
{item.country_code}
|
{item.country_code}
|
||||||
@@ -135,7 +147,10 @@ const TendersTable = () => {
|
|||||||
{formatDateTimeCell(item?.created_at ?? 0)}
|
{formatDateTimeCell(item?.created_at ?? 0)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className={cn(`text capitalize`)} colSpan={100}>
|
<TableCell
|
||||||
|
className={cn(`text capitalize`)}
|
||||||
|
colSpan={100}
|
||||||
|
>
|
||||||
<Status status={item.status}>{item.status}</Status>
|
<Status status={item.status}>{item.status}</Status>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="capitalize" colSpan={100}>
|
<TableCell className="capitalize" colSpan={100}>
|
||||||
@@ -162,7 +177,9 @@ const TendersTable = () => {
|
|||||||
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
|
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
|
||||||
onClick={() => navigateToTenderFeedback(item.id)}
|
onClick={() => navigateToTenderFeedback(item.id)}
|
||||||
>
|
>
|
||||||
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
|
<Tooltip
|
||||||
|
{..._TooltipDefaultParams({ id: "feedback" })}
|
||||||
|
/>
|
||||||
<ExclamationIcon />
|
<ExclamationIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,18 +191,50 @@ const TendersTable = () => {
|
|||||||
)}
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
</TableSortProvider>
|
</TableSortProvider>
|
||||||
|
<Tooltip
|
||||||
|
{..._TooltipDefaultParams({
|
||||||
|
id: "tender-title",
|
||||||
|
styles: {
|
||||||
|
maxWidth: "320px",
|
||||||
|
whiteSpace: "normal",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
textAlign: "start",
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<IsVisible condition={shouldShowPagination}>
|
<IsVisible condition={shouldShowPagination}>
|
||||||
<Pagination
|
<nav
|
||||||
key={`pagination-tenders-${absoluteCurrentPage}`}
|
className="mt-2 flex w-full items-center justify-center gap-3 text-sm"
|
||||||
currentPage={pagination.currentPage}
|
aria-label="Tender pagination"
|
||||||
totalPages={pagination.totalPages}
|
>
|
||||||
onPageChange={handlePaginationChange}
|
<button
|
||||||
pageRangeDisplayed={3}
|
type="button"
|
||||||
marginPagesDisplayed={1}
|
disabled={!hasPrevious}
|
||||||
pageLabelBuilder={buildWindowedPageLabel}
|
onClick={() =>
|
||||||
variant="tenders"
|
handlePaginationChange({ selected: absoluteCurrentPage - 1 })
|
||||||
/>
|
}
|
||||||
|
className="shadow-theme-xs rounded-xl border border-stroke/80 bg-white/70 px-4 py-2 font-semibold text-dark transition hover:border-primary/35 hover:text-primary disabled:cursor-not-allowed disabled:opacity-40 dark:border-dark-3 dark:bg-dark-2/70 dark:text-white"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className="text-gray-600 dark:text-gray-300"
|
||||||
|
aria-current="page"
|
||||||
|
>
|
||||||
|
Page {pagination.currentPage + 1}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={() =>
|
||||||
|
handlePaginationChange({ selected: absoluteCurrentPage + 1 })
|
||||||
|
}
|
||||||
|
className="shadow-theme-xs rounded-xl border border-stroke/80 bg-white/70 px-4 py-2 font-semibold text-dark transition hover:border-primary/35 hover:text-primary disabled:cursor-not-allowed disabled:opacity-40 dark:border-dark-3 dark:bg-dark-2/70 dark:text-white"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
</IsVisible>
|
</IsVisible>
|
||||||
</ListWrapper>
|
</ListWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,13 +8,20 @@ import { TCustomer } from "@/lib/api";
|
|||||||
import {
|
import {
|
||||||
buildQueryString,
|
buildQueryString,
|
||||||
deleteEmptyKeys,
|
deleteEmptyKeys,
|
||||||
getPaginatedRowNumber,
|
isDateRangeActive,
|
||||||
unixToDate,
|
unixToDate,
|
||||||
} from "@/utils/shared";
|
} from "@/utils/shared";
|
||||||
import gsap from "gsap";
|
import gsap from "gsap";
|
||||||
import { useIsMutating } from "@tanstack/react-query";
|
import { useIsMutating } from "@tanstack/react-query";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
const COMMA_SEPARATED_FILTER_KEYS = new Set([
|
const COMMA_SEPARATED_FILTER_KEYS = new Set([
|
||||||
@@ -172,6 +179,7 @@ const mergeTenderFilterFormState = (params: Record<string, any>) => {
|
|||||||
return {
|
return {
|
||||||
...TENDER_FILTER_FORM_DEFAULTS,
|
...TENDER_FILTER_FORM_DEFAULTS,
|
||||||
...built,
|
...built,
|
||||||
|
documents_scraped: built.documents_scraped === true,
|
||||||
created_at_range: built.created_at_range ?? [],
|
created_at_range: built.created_at_range ?? [],
|
||||||
tender_deadline_range: built.tender_deadline_range ?? [],
|
tender_deadline_range: built.tender_deadline_range ?? [],
|
||||||
publication_date_range: built.publication_date_range ?? [],
|
publication_date_range: built.publication_date_range ?? [],
|
||||||
@@ -293,6 +301,40 @@ const TENDER_FILTER_MODAL_KEYS = new Set<string>([
|
|||||||
...TENDER_RANGE_PARAM_KEYS,
|
...TENDER_RANGE_PARAM_KEYS,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applying a date-range filter retargets the table sort to that same field, so the
|
||||||
|
* results lead with the dates the user just narrowed to. Ordered by precedence — the
|
||||||
|
* first active range wins when several are applied at once. `sortOrder` mirrors each
|
||||||
|
* column's default click direction (see `columnDefaultOrder`).
|
||||||
|
*/
|
||||||
|
const SORT_TARGET_BY_RANGE_KEY: Array<{
|
||||||
|
rangeKey: string;
|
||||||
|
sortBy: string;
|
||||||
|
sortOrder: SortDirection;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
rangeKey: "publication_date_range",
|
||||||
|
sortBy: "publication_date",
|
||||||
|
sortOrder: "desc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rangeKey: "submission_deadline_range",
|
||||||
|
sortBy: "submission_deadline",
|
||||||
|
sortOrder: "asc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rangeKey: "tender_deadline_range",
|
||||||
|
sortBy: "tender_deadline",
|
||||||
|
sortOrder: "asc",
|
||||||
|
},
|
||||||
|
{ rangeKey: "created_at_range", sortBy: "created_at", sortOrder: "desc" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const resolveSortFromActiveDateFilters = (formData: Record<string, any>) =>
|
||||||
|
SORT_TARGET_BY_RANGE_KEY.find((target) =>
|
||||||
|
isDateRangeActive(formData[target.rangeKey]),
|
||||||
|
) ?? null;
|
||||||
|
|
||||||
const useTenderListPresenter = () => {
|
const useTenderListPresenter = () => {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [currentTender, setCurrentTender] = useState<TCustomer | null>(null);
|
const [currentTender, setCurrentTender] = useState<TCustomer | null>(null);
|
||||||
@@ -321,11 +363,10 @@ const useTenderListPresenter = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newParams: Record<string, any> = {
|
const newParams: Record<string, any> = {
|
||||||
...apiDefaultParams,
|
...apiDefaultParams,
|
||||||
offset: 0,
|
limit: 20,
|
||||||
limit: 10,
|
|
||||||
};
|
};
|
||||||
for (const key of searchParams.keys()) {
|
for (const key of searchParams.keys()) {
|
||||||
if (key === "cpv_codes") {
|
if (key === "cpv_codes" || key === "include_total" || key === "cursor") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
newParams[key] = searchParams.get(key);
|
newParams[key] = searchParams.get(key);
|
||||||
@@ -341,8 +382,13 @@ const useTenderListPresenter = () => {
|
|||||||
|
|
||||||
const { data, error, isPending, isError } = useTendersQuery(params);
|
const { data, error, isPending, isError } = useTendersQuery(params);
|
||||||
|
|
||||||
const { pagination, handlePaginationChange, buildFirstPageParams } =
|
const {
|
||||||
useHybridPagination({
|
pagination,
|
||||||
|
handlePaginationChange: changePaginationPage,
|
||||||
|
buildFirstPageParams,
|
||||||
|
hasNext,
|
||||||
|
hasPrevious,
|
||||||
|
} = useHybridPagination({
|
||||||
meta: data?.meta,
|
meta: data?.meta,
|
||||||
params,
|
params,
|
||||||
setParams,
|
setParams,
|
||||||
@@ -350,44 +396,23 @@ const useTenderListPresenter = () => {
|
|||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
|
||||||
const MAX_PAGE_JUMP = 10;
|
const handlePaginationChange = useCallback(
|
||||||
const paginationWindow = useMemo(() => {
|
(event: { selected: number }) => {
|
||||||
const windowStart = Math.max(0, pagination.currentPage - MAX_PAGE_JUMP);
|
if (typeof window !== "undefined") {
|
||||||
const windowEnd = Math.min(
|
const reduceMotion = window.matchMedia(
|
||||||
Math.max(pagination.totalPages - 1, 0),
|
"(prefers-reduced-motion: reduce)",
|
||||||
pagination.currentPage + MAX_PAGE_JUMP,
|
).matches;
|
||||||
);
|
window.scrollTo({
|
||||||
return {
|
top: 0,
|
||||||
windowStart,
|
behavior: reduceMotion ? "auto" : "smooth",
|
||||||
virtualCurrent: pagination.currentPage - windowStart,
|
|
||||||
virtualTotal: Math.max(windowEnd - windowStart + 1, 1),
|
|
||||||
};
|
|
||||||
}, [pagination.currentPage, pagination.totalPages]);
|
|
||||||
|
|
||||||
const handleWindowedPaginationChange = useCallback(
|
|
||||||
(e: { selected: number }) => {
|
|
||||||
handlePaginationChange({
|
|
||||||
selected: e.selected + paginationWindow.windowStart,
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
changePaginationPage(event);
|
||||||
},
|
},
|
||||||
[handlePaginationChange, paginationWindow.windowStart],
|
[changePaginationPage],
|
||||||
);
|
);
|
||||||
|
|
||||||
const buildWindowedPageLabel = useCallback(
|
const shouldShowPagination = !isPending && (hasPrevious || hasNext);
|
||||||
(virtualPage: number) => String(virtualPage + paginationWindow.windowStart),
|
|
||||||
[paginationWindow.windowStart],
|
|
||||||
);
|
|
||||||
|
|
||||||
const windowedPagination = useMemo(
|
|
||||||
() => ({
|
|
||||||
currentPage: paginationWindow.virtualCurrent,
|
|
||||||
totalPages: paginationWindow.virtualTotal,
|
|
||||||
}),
|
|
||||||
[paginationWindow.virtualCurrent, paginationWindow.virtualTotal],
|
|
||||||
);
|
|
||||||
|
|
||||||
const shouldShowPagination =
|
|
||||||
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
|
||||||
|
|
||||||
const tendersList = data?.data?.tenders ?? [];
|
const tendersList = data?.data?.tenders ?? [];
|
||||||
|
|
||||||
@@ -414,12 +439,11 @@ const useTenderListPresenter = () => {
|
|||||||
|
|
||||||
const getRowNumber = useCallback(
|
const getRowNumber = useCallback(
|
||||||
(index: number) =>
|
(index: number) =>
|
||||||
getPaginatedRowNumber({
|
pagination.currentPage *
|
||||||
index,
|
(data?.meta?.limit ?? Number(params.limit) ?? 20) +
|
||||||
page: data?.meta?.page,
|
index +
|
||||||
limit: data?.meta?.limit,
|
1,
|
||||||
}),
|
[data?.meta?.limit, pagination.currentPage, params.limit],
|
||||||
[data?.meta?.limit, data?.meta?.page],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const formatDateTimeCell = useCallback((unix: number) => {
|
const formatDateTimeCell = useCallback((unix: number) => {
|
||||||
@@ -482,7 +506,22 @@ const useTenderListPresenter = () => {
|
|||||||
delete merged[key];
|
delete merged[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const newParams = buildFirstPageParams({ ...merged, ...normalizedData });
|
|
||||||
|
// Retarget the sort to a freshly applied date filter, but only while the user is
|
||||||
|
// on the implicit default sort — never clobber a column they sorted on by hand.
|
||||||
|
const sortTarget = resolveSortFromActiveDateFilters(data);
|
||||||
|
const currentSortBy = params.sort_by as string | undefined;
|
||||||
|
const isDefaultSort = !currentSortBy || currentSortBy === "created_at";
|
||||||
|
const sortOverride =
|
||||||
|
sortTarget && isDefaultSort
|
||||||
|
? { sort_by: sortTarget.sortBy, sort_order: sortTarget.sortOrder }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const newParams = buildFirstPageParams({
|
||||||
|
...merged,
|
||||||
|
...normalizedData,
|
||||||
|
...sortOverride,
|
||||||
|
});
|
||||||
const cleanedParams = deleteEmptyKeys(newParams);
|
const cleanedParams = deleteEmptyKeys(newParams);
|
||||||
const queryString = buildQueryString(cleanedParams);
|
const queryString = buildQueryString(cleanedParams);
|
||||||
router.push(`${pathName}?${queryString}`);
|
router.push(`${pathName}?${queryString}`);
|
||||||
@@ -491,7 +530,7 @@ const useTenderListPresenter = () => {
|
|||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
const cleared = buildFirstPageParams({
|
const cleared = buildFirstPageParams({
|
||||||
...apiDefaultParams,
|
...apiDefaultParams,
|
||||||
limit: 10,
|
limit: 20,
|
||||||
});
|
});
|
||||||
filterFormReset(mergeTenderFilterFormState(cleared));
|
filterFormReset(mergeTenderFilterFormState(cleared));
|
||||||
router.push(pathName);
|
router.push(pathName);
|
||||||
@@ -579,11 +618,12 @@ const useTenderListPresenter = () => {
|
|||||||
tendersList,
|
tendersList,
|
||||||
navigateToTenderDetails,
|
navigateToTenderDetails,
|
||||||
navigateToTenderFeedback,
|
navigateToTenderFeedback,
|
||||||
handlePaginationChange: handleWindowedPaginationChange,
|
handlePaginationChange,
|
||||||
buildWindowedPageLabel,
|
hasNext,
|
||||||
|
hasPrevious,
|
||||||
getRowNumber,
|
getRowNumber,
|
||||||
formatDateTimeCell,
|
formatDateTimeCell,
|
||||||
pagination: windowedPagination,
|
pagination,
|
||||||
absoluteCurrentPage: pagination.currentPage,
|
absoluteCurrentPage: pagination.currentPage,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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";
|
"use client";
|
||||||
import { PasswordIcon } from "@/assets/icons";
|
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
|
||||||
import {
|
|
||||||
createPhoneFieldRules,
|
|
||||||
PhoneNumberInput,
|
|
||||||
} from "@/components/FormElements/PhoneNumberInput";
|
|
||||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
|
||||||
import FileImageUploadCard from "@/components/ui/FileImageUploadCard";
|
|
||||||
import FormFooter from "@/components/ui/FormFooter";
|
import FormFooter from "@/components/ui/FormFooter";
|
||||||
import { FormErrorMessages } from "@/constants/Texts";
|
|
||||||
import { ICreateAdminCredentials } from "@/lib/api";
|
import { ICreateAdminCredentials } from "@/lib/api";
|
||||||
import { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { Controller } from "react-hook-form";
|
import useCreateAdminPresenter, {
|
||||||
import useCreateAdminPresenter from "./useCreateAdminPresenter";
|
type CreateAdminFormValues,
|
||||||
|
} from "./useCreateAdminPresenter";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
editMode?: boolean;
|
editMode?: boolean;
|
||||||
defaultValues?: ICreateAdminCredentials;
|
defaultValues?: ICreateAdminCredentials;
|
||||||
id?: string;
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
|
const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
|
||||||
const {
|
const presenter = useCreateAdminPresenter({ editMode, defaultValues, id });
|
||||||
errors,
|
const { errors, handleSubmit, register, control, onSubmit, sections } =
|
||||||
handleSubmit,
|
presenter;
|
||||||
register,
|
|
||||||
control,
|
|
||||||
onSubmit,
|
|
||||||
isUploadingImage,
|
|
||||||
uploadProgress,
|
|
||||||
selectedFileName,
|
|
||||||
selectedFileSize,
|
|
||||||
previewUrl,
|
|
||||||
clearSelectedProfileImage,
|
|
||||||
profileImageRegister,
|
|
||||||
passwordFieldInputType,
|
|
||||||
setPasswordFieldInputType,
|
|
||||||
} = useCreateAdminPresenter({
|
|
||||||
editMode,
|
|
||||||
defaultValues,
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<DynamicFormBuilder<CreateAdminFormValues>
|
||||||
className="grid grid-cols-1 gap-9"
|
sections={sections}
|
||||||
onSubmit={handleSubmit((data) => {
|
register={register}
|
||||||
onSubmit(data);
|
|
||||||
})}
|
|
||||||
data-cy={editMode ? "admin-edit-form" : "admin-create-form"}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-9">
|
|
||||||
<ShowcaseSection
|
|
||||||
title="Input Fields"
|
|
||||||
className="grid grid-cols-1 gap-3 md:grid-cols-2"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<InputGroup
|
|
||||||
{...register("full_name", {
|
|
||||||
required: { message: FormErrorMessages.required, value: true },
|
|
||||||
minLength: {
|
|
||||||
value: 5,
|
|
||||||
message: FormErrorMessages.minLength(5),
|
|
||||||
},
|
|
||||||
maxLength: {
|
|
||||||
value: 100,
|
|
||||||
message: FormErrorMessages.maxLength(100),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
label="Full name"
|
|
||||||
placeholder="Enter full name (5-100 characters)"
|
|
||||||
required
|
|
||||||
type="text"
|
|
||||||
name="full_name"
|
|
||||||
dataCy="admin-form-full-name-input"
|
|
||||||
/>
|
|
||||||
{errors.full_name && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.full_name.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<InputGroup
|
|
||||||
label="Username"
|
|
||||||
placeholder="Letters and numbers only (3-30)"
|
|
||||||
autoComplete="off"
|
|
||||||
required
|
|
||||||
type="text"
|
|
||||||
{...register("username", {
|
|
||||||
required: {
|
|
||||||
value: true,
|
|
||||||
message: FormErrorMessages.required,
|
|
||||||
},
|
|
||||||
pattern: {
|
|
||||||
value: /^[a-zA-Z0-9]+$/,
|
|
||||||
message: FormErrorMessages.alphaNumeric,
|
|
||||||
},
|
|
||||||
minLength: {
|
|
||||||
value: 3,
|
|
||||||
message: FormErrorMessages.minLength(3),
|
|
||||||
},
|
|
||||||
maxLength: {
|
|
||||||
value: 30,
|
|
||||||
message: FormErrorMessages.maxLength(30),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
name="username"
|
|
||||||
dataCy="admin-form-username-input"
|
|
||||||
/>
|
|
||||||
{errors.username && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.username.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<InputGroup
|
|
||||||
label="Email"
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
{...register("email", {
|
|
||||||
required: {
|
|
||||||
value: true,
|
|
||||||
message: FormErrorMessages.required,
|
|
||||||
},
|
|
||||||
pattern: {
|
|
||||||
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
||||||
message: FormErrorMessages.invalidEmail,
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
name="email"
|
|
||||||
dataCy="admin-form-email-input"
|
|
||||||
/>
|
|
||||||
{errors.email && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.email.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!editMode && (
|
|
||||||
<div>
|
|
||||||
<InputGroup
|
|
||||||
label="Password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
placeholder="At least 8 characters"
|
|
||||||
required
|
|
||||||
icon={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setPasswordFieldInputType(
|
|
||||||
passwordFieldInputType === "password"
|
|
||||||
? "text"
|
|
||||||
: "password",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="absolute right-4.5 top-1/2 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-dark-5 transition hover:text-primary dark:text-white/75 [&>svg]:!static [&>svg]:!size-5 [&>svg]:!translate-y-0"
|
|
||||||
aria-label={
|
|
||||||
passwordFieldInputType === "password"
|
|
||||||
? "Show password"
|
|
||||||
: "Hide password"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PasswordIcon className="pointer-events-none" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
type={passwordFieldInputType}
|
|
||||||
{...register("password", {
|
|
||||||
required: {
|
|
||||||
value: true,
|
|
||||||
message: FormErrorMessages.required,
|
|
||||||
},
|
|
||||||
minLength: {
|
|
||||||
value: 8,
|
|
||||||
message: FormErrorMessages.minLength(8),
|
|
||||||
},
|
|
||||||
maxLength: {
|
|
||||||
value: 128,
|
|
||||||
message: FormErrorMessages.maxLength(128),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
name="password"
|
|
||||||
dataCy="admin-form-password-input"
|
|
||||||
/>
|
|
||||||
{errors.password && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.password.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Controller
|
|
||||||
name="phone"
|
|
||||||
control={control}
|
control={control}
|
||||||
rules={createPhoneFieldRules({ required: false })}
|
errors={errors}
|
||||||
render={({ field }) => (
|
handleSubmit={handleSubmit}
|
||||||
<PhoneNumberInput
|
onSubmit={onSubmit}
|
||||||
label="Phone"
|
dataCy={editMode ? "admin-edit-form" : "admin-create-form"}
|
||||||
value={field.value ?? ""}
|
footer={<FormFooter />}
|
||||||
onChange={field.onChange}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
error={errors.phone?.message as string | undefined}
|
|
||||||
dataCy="admin-form-phone-input"
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<InputGroup
|
|
||||||
label="Department"
|
|
||||||
type="text"
|
|
||||||
{...register("department", {
|
|
||||||
minLength: {
|
|
||||||
value: 2,
|
|
||||||
message: FormErrorMessages.minLength(2),
|
|
||||||
},
|
|
||||||
maxLength: {
|
|
||||||
value: 100,
|
|
||||||
message: FormErrorMessages.maxLength(100),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
name="department"
|
|
||||||
dataCy="admin-form-department-input"
|
|
||||||
/>
|
|
||||||
{errors.department && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.department.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<InputGroup
|
|
||||||
label="Position"
|
|
||||||
type="text"
|
|
||||||
{...register("position", {
|
|
||||||
minLength: {
|
|
||||||
value: 2,
|
|
||||||
message: FormErrorMessages.minLength(2),
|
|
||||||
},
|
|
||||||
maxLength: {
|
|
||||||
value: 100,
|
|
||||||
message: FormErrorMessages.maxLength(100),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
name="position"
|
|
||||||
dataCy="admin-form-position-input"
|
|
||||||
/>
|
|
||||||
{errors.position && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.position.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FileImageUploadCard
|
|
||||||
label="Profile image"
|
|
||||||
inputId="profile-image-upload"
|
|
||||||
fileRegister={profileImageRegister}
|
|
||||||
isLoading={isUploadingImage}
|
|
||||||
uploadProgress={uploadProgress}
|
|
||||||
previewUrl={previewUrl}
|
|
||||||
selectedFileName={selectedFileName}
|
|
||||||
selectedFileSize={selectedFileSize}
|
|
||||||
onClear={clearSelectedProfileImage}
|
|
||||||
errorMessage={errors.profile_image?.message as string | undefined}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<FormFooter />
|
|
||||||
</div>
|
|
||||||
</ShowcaseSection>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { ICreateAdminCredentials } from "@/lib/api";
|
||||||
|
|
||||||
|
export type CreateAdminFormValues = Omit<
|
||||||
|
ICreateAdminCredentials,
|
||||||
|
"profile_image"
|
||||||
|
> & {
|
||||||
|
profile_image?: FileList;
|
||||||
|
};
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { PasswordIcon } from "@/assets/icons";
|
||||||
|
import {
|
||||||
|
createPhoneFieldRules,
|
||||||
|
PhoneNumberInput,
|
||||||
|
} from "@/components/FormElements/PhoneNumberInput";
|
||||||
|
import type { DynamicFormSection } from "@/components/forms/DynamicFormBuilder";
|
||||||
|
import FileImageUploadCard from "@/components/ui/FileImageUploadCard";
|
||||||
|
import { FormErrorMessages } from "@/constants/Texts";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import { Controller, type UseFormRegisterReturn } from "react-hook-form";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import type { CreateAdminFormValues } from "./createAdminForm.types";
|
||||||
|
|
||||||
|
const adminSectionId = uuidv4();
|
||||||
|
|
||||||
|
interface CreateAdminFormSectionsOptions {
|
||||||
|
editMode?: boolean;
|
||||||
|
passwordFieldInputType: "password" | "text";
|
||||||
|
setPasswordFieldInputType: Dispatch<SetStateAction<"password" | "text">>;
|
||||||
|
profileImageRegister: UseFormRegisterReturn;
|
||||||
|
isUploadingImage: boolean;
|
||||||
|
uploadProgress: number;
|
||||||
|
previewUrl: string | null;
|
||||||
|
selectedFileName: string;
|
||||||
|
selectedFileSize: number | null;
|
||||||
|
clearSelectedProfileImage: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createAdminFormSections = ({
|
||||||
|
editMode,
|
||||||
|
passwordFieldInputType,
|
||||||
|
setPasswordFieldInputType,
|
||||||
|
profileImageRegister,
|
||||||
|
isUploadingImage,
|
||||||
|
uploadProgress,
|
||||||
|
previewUrl,
|
||||||
|
selectedFileName,
|
||||||
|
selectedFileSize,
|
||||||
|
clearSelectedProfileImage,
|
||||||
|
}: CreateAdminFormSectionsOptions): DynamicFormSection<CreateAdminFormValues>[] => [
|
||||||
|
{
|
||||||
|
id: adminSectionId,
|
||||||
|
title: "Input Fields",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "full_name",
|
||||||
|
label: "Full name",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "Enter full name (5-100 characters)",
|
||||||
|
required: true,
|
||||||
|
dataCy: "admin-form-full-name-input",
|
||||||
|
rules: {
|
||||||
|
required: { message: FormErrorMessages.required, value: true },
|
||||||
|
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
|
||||||
|
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "username",
|
||||||
|
label: "Username",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "Letters and numbers only (3-30)",
|
||||||
|
autoComplete: "off",
|
||||||
|
required: true,
|
||||||
|
dataCy: "admin-form-username-input",
|
||||||
|
rules: {
|
||||||
|
required: { value: true, message: FormErrorMessages.required },
|
||||||
|
pattern: {
|
||||||
|
value: /^[a-zA-Z0-9]+$/,
|
||||||
|
message: FormErrorMessages.alphaNumeric,
|
||||||
|
},
|
||||||
|
minLength: { value: 3, message: FormErrorMessages.minLength(3) },
|
||||||
|
maxLength: { value: 30, message: FormErrorMessages.maxLength(30) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "email",
|
||||||
|
label: "Email",
|
||||||
|
type: "email",
|
||||||
|
placeholder: "you@example.com",
|
||||||
|
autoComplete: "off",
|
||||||
|
required: true,
|
||||||
|
dataCy: "admin-form-email-input",
|
||||||
|
rules: {
|
||||||
|
required: { value: true, message: FormErrorMessages.required },
|
||||||
|
pattern: {
|
||||||
|
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||||
|
message: FormErrorMessages.invalidEmail,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "password",
|
||||||
|
label: "Password",
|
||||||
|
type: passwordFieldInputType,
|
||||||
|
placeholder: "At least 8 characters",
|
||||||
|
autoComplete: "new-password",
|
||||||
|
required: true,
|
||||||
|
hidden: editMode,
|
||||||
|
dataCy: "admin-form-password-input",
|
||||||
|
icon: (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setPasswordFieldInputType(
|
||||||
|
passwordFieldInputType === "password" ? "text" : "password",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="absolute right-4.5 top-1/2 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-dark-5 transition hover:text-primary dark:text-white/75 [&>svg]:!static [&>svg]:!size-5 [&>svg]:!translate-y-0"
|
||||||
|
aria-label={
|
||||||
|
passwordFieldInputType === "password"
|
||||||
|
? "Show password"
|
||||||
|
: "Hide password"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PasswordIcon className="pointer-events-none" />
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
rules: {
|
||||||
|
required: { value: true, message: FormErrorMessages.required },
|
||||||
|
minLength: { value: 8, message: FormErrorMessages.minLength(8) },
|
||||||
|
maxLength: { value: 128, message: FormErrorMessages.maxLength(128) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "phone",
|
||||||
|
render: ({ control, errors }) => (
|
||||||
|
<Controller
|
||||||
|
name="phone"
|
||||||
|
control={control}
|
||||||
|
rules={createPhoneFieldRules({ required: false })}
|
||||||
|
render={({ field }) => (
|
||||||
|
<PhoneNumberInput
|
||||||
|
label="Phone"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
error={errors.phone?.message as string | undefined}
|
||||||
|
dataCy="admin-form-phone-input"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "department",
|
||||||
|
label: "Department",
|
||||||
|
type: "text",
|
||||||
|
dataCy: "admin-form-department-input",
|
||||||
|
rules: {
|
||||||
|
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
|
||||||
|
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "position",
|
||||||
|
label: "Position",
|
||||||
|
type: "text",
|
||||||
|
dataCy: "admin-form-position-input",
|
||||||
|
rules: {
|
||||||
|
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
|
||||||
|
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "profile_image",
|
||||||
|
render: ({ error }) => (
|
||||||
|
<FileImageUploadCard
|
||||||
|
label="Profile image"
|
||||||
|
inputId="profile-image-upload"
|
||||||
|
fileRegister={profileImageRegister}
|
||||||
|
isLoading={isUploadingImage}
|
||||||
|
uploadProgress={uploadProgress}
|
||||||
|
previewUrl={previewUrl}
|
||||||
|
selectedFileName={selectedFileName}
|
||||||
|
selectedFileSize={selectedFileSize}
|
||||||
|
onClear={clearSelectedProfileImage}
|
||||||
|
errorMessage={error}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -5,9 +5,13 @@ import { normalizePhoneFieldValue } from "@/components/FormElements/PhoneNumberI
|
|||||||
import { validateImageFile } from "@/utils/shared";
|
import { validateImageFile } from "@/utils/shared";
|
||||||
import { useIsMutating } from "@tanstack/react-query";
|
import { useIsMutating } from "@tanstack/react-query";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ChangeEvent, useEffect, useState } from "react";
|
import { ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { SubmitHandler, useForm } from "react-hook-form";
|
import { SubmitHandler, useForm } from "react-hook-form";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
import { createAdminFormSections } from "./createAdminFormSections";
|
||||||
|
import type { CreateAdminFormValues } from "./createAdminForm.types";
|
||||||
|
|
||||||
|
export type { CreateAdminFormValues } from "./createAdminForm.types";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
editMode?: boolean;
|
editMode?: boolean;
|
||||||
@@ -15,10 +19,6 @@ interface IProps {
|
|||||||
id?: string;
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateAdminFormValues = Omit<ICreateAdminCredentials, "profile_image"> & {
|
|
||||||
profile_image?: FileList;
|
|
||||||
};
|
|
||||||
|
|
||||||
const useCreateAdminPresenter = ({
|
const useCreateAdminPresenter = ({
|
||||||
editMode = false,
|
editMode = false,
|
||||||
defaultValues,
|
defaultValues,
|
||||||
@@ -33,8 +33,9 @@ const useCreateAdminPresenter = ({
|
|||||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState<number>(0);
|
const [uploadProgress, setUploadProgress] = useState<number>(0);
|
||||||
const [uploadedProfileImageUrl, setUploadedProfileImageUrl] = useState<
|
const [uploadedProfileImageUrl, setUploadedProfileImageUrl] = useState<
|
||||||
string | undefined
|
string | null | undefined
|
||||||
>(defaultValues?.profile_image);
|
>(defaultValues?.profile_image);
|
||||||
|
const profileImageInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
// The existing image rendered as a local (authenticated) object URL — used as
|
// The existing image rendered as a local (authenticated) object URL — used as
|
||||||
// the preview baseline so "remove/replace" can fall back to it.
|
// the preview baseline so "remove/replace" can fall back to it.
|
||||||
const [defaultPreviewUrl, setDefaultPreviewUrl] = useState<string | null>(
|
const [defaultPreviewUrl, setDefaultPreviewUrl] = useState<string | null>(
|
||||||
@@ -144,8 +145,15 @@ const useCreateAdminPresenter = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearSelectedProfileImage = () => {
|
const clearSelectedProfileImage = () => {
|
||||||
|
if (profileImageInputRef.current) {
|
||||||
|
profileImageInputRef.current.value = "";
|
||||||
|
}
|
||||||
setValue("profile_image", undefined);
|
setValue("profile_image", undefined);
|
||||||
resetToDefaultProfileImage();
|
setSelectedFileName("");
|
||||||
|
setSelectedFileSize(null);
|
||||||
|
setUploadProgress(0);
|
||||||
|
setUploadedProfileImageUrl(null);
|
||||||
|
setPreviewUrl(null);
|
||||||
clearErrors("profile_image");
|
clearErrors("profile_image");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -204,10 +212,17 @@ const useCreateAdminPresenter = ({
|
|||||||
const profileImageRegister = register("profile_image", {
|
const profileImageRegister = register("profile_image", {
|
||||||
onChange: onProfileImageChange,
|
onChange: onProfileImageChange,
|
||||||
});
|
});
|
||||||
|
const profileImageFileRegister = {
|
||||||
|
...profileImageRegister,
|
||||||
|
ref: (element: HTMLInputElement | null) => {
|
||||||
|
profileImageInputRef.current = element;
|
||||||
|
profileImageRegister.ref(element);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit: SubmitHandler<CreateAdminFormValues> = async (data) => {
|
const onSubmit: SubmitHandler<CreateAdminFormValues> = async (data) => {
|
||||||
const file = data.profile_image?.[0];
|
const file = data.profile_image?.[0];
|
||||||
const profileImageUrl = uploadedProfileImageUrl ?? defaultValues?.profile_image;
|
const profileImageUrl = uploadedProfileImageUrl;
|
||||||
|
|
||||||
if (file && !profileImageUrl) {
|
if (file && !profileImageUrl) {
|
||||||
toast.error("Please wait for profile image upload to finish");
|
toast.error("Please wait for profile image upload to finish");
|
||||||
@@ -229,6 +244,34 @@ const useCreateAdminPresenter = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const sections = useMemo(
|
||||||
|
() =>
|
||||||
|
createAdminFormSections({
|
||||||
|
editMode,
|
||||||
|
passwordFieldInputType,
|
||||||
|
setPasswordFieldInputType,
|
||||||
|
profileImageRegister: profileImageFileRegister,
|
||||||
|
isUploadingImage,
|
||||||
|
uploadProgress,
|
||||||
|
previewUrl,
|
||||||
|
selectedFileName,
|
||||||
|
selectedFileSize,
|
||||||
|
clearSelectedProfileImage,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
editMode,
|
||||||
|
passwordFieldInputType,
|
||||||
|
profileImageFileRegister,
|
||||||
|
isUploadingImage,
|
||||||
|
uploadProgress,
|
||||||
|
previewUrl,
|
||||||
|
selectedFileName,
|
||||||
|
selectedFileSize,
|
||||||
|
clearSelectedProfileImage,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
errors,
|
errors,
|
||||||
register,
|
register,
|
||||||
@@ -244,9 +287,10 @@ const useCreateAdminPresenter = ({
|
|||||||
selectedFileSize,
|
selectedFileSize,
|
||||||
previewUrl,
|
previewUrl,
|
||||||
clearSelectedProfileImage,
|
clearSelectedProfileImage,
|
||||||
profileImageRegister,
|
profileImageRegister: profileImageFileRegister,
|
||||||
passwordFieldInputType,
|
passwordFieldInputType,
|
||||||
setPasswordFieldInputType,
|
setPasswordFieldInputType,
|
||||||
|
sections,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { GlobeIcon, MoneyIcon } from "@/assets/icons";
|
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
|
||||||
import TagInput from "@/components/FormElements/InputGroup/tag-input";
|
|
||||||
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
|
|
||||||
import MultiSelect from "@/components/FormElements/MultiSelect";
|
|
||||||
import { Select } from "@/components/FormElements/select";
|
|
||||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
|
||||||
import FormFooter from "@/components/ui/FormFooter";
|
import FormFooter from "@/components/ui/FormFooter";
|
||||||
import { REGEX } from "@/constants/regex";
|
import type { ICreateCompanyCredentials } from "@/lib/api";
|
||||||
import { FormErrorMessages } from "@/constants/Texts";
|
import { createCompanyFormSections } from "./companyFormSections";
|
||||||
import { ICreateCompanyCredentials } from "@/lib/api/types/Company";
|
|
||||||
import useCreateCompanyPresenter from "./useCreateCompanyPresenter";
|
import useCreateCompanyPresenter from "./useCreateCompanyPresenter";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
@@ -24,507 +18,49 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
watch,
|
watch,
|
||||||
setValue,
|
setValue,
|
||||||
|
persistDocumentFileIds,
|
||||||
companyCategories,
|
companyCategories,
|
||||||
} = useCreateCompanyPresenter({
|
} = useCreateCompanyPresenter({ editMode, defaultValues, id });
|
||||||
editMode,
|
|
||||||
defaultValues,
|
const categoryItems =
|
||||||
id,
|
companyCategories?.data?.categories?.map((category) => ({
|
||||||
});
|
label: category.name,
|
||||||
|
value: category.id,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<DynamicFormBuilder<ICreateCompanyCredentials>
|
||||||
className="grid grid-cols-1 items-start gap-9 rounded-xl bg-gray-1 p-10 dark:bg-gray-7 md:grid-cols-2"
|
sections={createCompanyFormSections({
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
editMode,
|
||||||
>
|
id,
|
||||||
<div className="flex flex-col gap-9">
|
categoryItems,
|
||||||
<ShowcaseSection
|
watch,
|
||||||
title="Company Information"
|
setValue,
|
||||||
className="flex flex-col gap-5"
|
persistDocumentFileIds,
|
||||||
>
|
|
||||||
<InputGroup
|
|
||||||
{...register("name", {
|
|
||||||
required: { message: FormErrorMessages.required, value: true },
|
|
||||||
minLength: {
|
|
||||||
value: 2,
|
|
||||||
message: FormErrorMessages.minLength(2),
|
|
||||||
},
|
|
||||||
maxLength: {
|
|
||||||
value: 200,
|
|
||||||
message: FormErrorMessages.maxLength(200),
|
|
||||||
},
|
|
||||||
})}
|
})}
|
||||||
label="Name"
|
|
||||||
name="name"
|
|
||||||
required
|
|
||||||
type="text"
|
|
||||||
placeholder="Legal name as registered"
|
|
||||||
/>
|
|
||||||
{errors.name && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">{errors.name.message}</p>
|
|
||||||
)}
|
|
||||||
<InputGroup
|
|
||||||
{...register("email", {
|
|
||||||
required: { message: FormErrorMessages.required, value: true },
|
|
||||||
pattern: {
|
|
||||||
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
||||||
message: FormErrorMessages.invalidEmail,
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
label="Email"
|
|
||||||
name="email"
|
|
||||||
required
|
|
||||||
type="email"
|
|
||||||
placeholder="name@company.com"
|
|
||||||
/>
|
|
||||||
{errors.email && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">{errors.email.message}</p>
|
|
||||||
)}
|
|
||||||
<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,
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
label="Phone"
|
|
||||||
name="phone"
|
|
||||||
required
|
|
||||||
type="tel"
|
|
||||||
placeholder="+971 50 123 4567"
|
|
||||||
/>
|
|
||||||
{errors.phone && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">{errors.phone.message}</p>
|
|
||||||
)}
|
|
||||||
<Select
|
|
||||||
{...register("type", {
|
|
||||||
required: { message: FormErrorMessages.required, value: true },
|
|
||||||
})}
|
|
||||||
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 />}
|
|
||||||
/>
|
|
||||||
{errors.type && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">{errors.type.message}</p>
|
|
||||||
)}
|
|
||||||
<InputGroup
|
|
||||||
{...register("registration_number", {
|
|
||||||
required: { message: FormErrorMessages.required, value: true },
|
|
||||||
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
|
|
||||||
maxLength: {
|
|
||||||
value: 50,
|
|
||||||
message: FormErrorMessages.maxLength(50),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
label="Registration Number"
|
|
||||||
name="registration_number"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
placeholder="Registry or license number"
|
|
||||||
/>
|
|
||||||
{errors.registration_number && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.registration_number.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<InputGroup
|
|
||||||
{...register("tax_id", {
|
|
||||||
required: { message: FormErrorMessages.required, value: true },
|
|
||||||
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
|
|
||||||
maxLength: {
|
|
||||||
value: 50,
|
|
||||||
message: FormErrorMessages.maxLength(50),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
label="Tax id"
|
|
||||||
name="tax_id"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
placeholder="National tax identification number"
|
|
||||||
/>
|
|
||||||
{errors.tax_id && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">{errors.tax_id.message}</p>
|
|
||||||
)}
|
|
||||||
<InputGroup
|
|
||||||
{...register("industry", {
|
|
||||||
required: { message: FormErrorMessages.required, value: true },
|
|
||||||
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
|
|
||||||
maxLength: {
|
|
||||||
value: 100,
|
|
||||||
message: FormErrorMessages.maxLength(100),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
label="Industry"
|
|
||||||
name="industry"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
placeholder="e.g. construction, software, logistics"
|
|
||||||
/>
|
|
||||||
{errors.industry && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.industry.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<TextAreaGroup
|
|
||||||
label="Description"
|
|
||||||
{...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>
|
|
||||||
)}
|
|
||||||
<InputGroup
|
|
||||||
{...register("website", {
|
|
||||||
required: { message: FormErrorMessages.required, value: true },
|
|
||||||
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
|
|
||||||
maxLength: {
|
|
||||||
value: 100,
|
|
||||||
message: FormErrorMessages.maxLength(100),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
label="Website"
|
|
||||||
required
|
|
||||||
name="website"
|
|
||||||
type="url"
|
|
||||||
placeholder="https://www.example.com"
|
|
||||||
/>
|
|
||||||
{errors.website && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.website.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</ShowcaseSection>
|
|
||||||
|
|
||||||
<ShowcaseSection title="Settings" className="grid grid-cols-1 gap-5">
|
|
||||||
<Select
|
|
||||||
{...register("language")}
|
|
||||||
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 />}
|
|
||||||
/>
|
|
||||||
{errors.language && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.language.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Select
|
|
||||||
{...register("currency")}
|
|
||||||
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 />}
|
|
||||||
/>
|
|
||||||
{errors.currency && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.currency.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<InputGroup
|
|
||||||
{...register("timezone", {
|
|
||||||
minLength: { value: 3, message: FormErrorMessages.minLength(3) },
|
|
||||||
maxLength: {
|
|
||||||
value: 50,
|
|
||||||
message: FormErrorMessages.maxLength(50),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
label="Timezone"
|
|
||||||
name="timezone"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
{errors.timezone && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.timezone.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</ShowcaseSection>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-9">
|
|
||||||
<ShowcaseSection
|
|
||||||
title="Business Information"
|
|
||||||
className="grid grid-cols-1 gap-5"
|
|
||||||
>
|
|
||||||
<InputGroup
|
|
||||||
{...register("employee_count", {
|
|
||||||
min: { value: 1, message: FormErrorMessages.min(1) },
|
|
||||||
max: { value: 100000, message: FormErrorMessages.max(100000) },
|
|
||||||
})}
|
|
||||||
label="Employee Count"
|
|
||||||
name="employee_count"
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
{errors.employee_count && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.employee_count.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<InputGroup
|
|
||||||
{...register("annual_revenue", {
|
|
||||||
min: { value: 0, message: FormErrorMessages.min(0) },
|
|
||||||
max: {
|
|
||||||
value: 999999999999,
|
|
||||||
message: FormErrorMessages.max(999999999999),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
label="Annual Revenue"
|
|
||||||
name="annual_revenue"
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
{errors.annual_revenue && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.annual_revenue.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<InputGroup
|
|
||||||
{...register("founded_year", {
|
|
||||||
min: { value: 1800, message: FormErrorMessages.min(1800) },
|
|
||||||
max: {
|
|
||||||
value: 2100,
|
|
||||||
message: FormErrorMessages.max(2100),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
label="Founded Year"
|
|
||||||
name="founded_year"
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
{errors.founded_year && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.founded_year.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</ShowcaseSection>
|
|
||||||
<ShowcaseSection title="Address" className="grid grid-cols-1 gap-5">
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
{errors.address?.street && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.address.street.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
{errors.address?.city && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.address.city.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
{errors.address?.state && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.address.state.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
{errors.address?.postal_code && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.address.postal_code.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
{errors.address?.country && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.address.country.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</ShowcaseSection>
|
|
||||||
<ShowcaseSection title="Tags" className="grid grid-cols-1 gap-5">
|
|
||||||
<TagInput
|
|
||||||
{...register("tags.keywords")}
|
|
||||||
name="tags.keywords"
|
|
||||||
label="Keywords"
|
|
||||||
placeholder=""
|
|
||||||
register={register}
|
register={register}
|
||||||
setValue={setValue}
|
control={control}
|
||||||
watch={watch}
|
errors={errors}
|
||||||
/>
|
handleSubmit={handleSubmit}
|
||||||
<MultiSelect
|
onSubmit={onSubmit}
|
||||||
{...register("tags.categories")}
|
className="flex flex-col gap-6"
|
||||||
value={watch("tags.categories")}
|
sectionsClassName="grid grid-cols-1 items-start gap-6 lg:grid-cols-2"
|
||||||
name="tags.categories"
|
header={
|
||||||
label="Categories"
|
<header className="flex flex-col gap-1">
|
||||||
items={
|
<h1 className="text-xl font-bold text-dark dark:text-white">
|
||||||
companyCategories
|
{editMode ? "Edit company" : "New company"}
|
||||||
? companyCategories?.data?.categories?.map((c) => {
|
</h1>
|
||||||
return {
|
<p className="text-sm text-dark-5 dark:text-dark-6">
|
||||||
label: c.name,
|
Profile, business details, address, tags, documents, and links.
|
||||||
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=""
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TagInput
|
|
||||||
{...register("tags.specializations")}
|
|
||||||
label="Specializations"
|
|
||||||
name="tags.specializations"
|
|
||||||
watch={watch}
|
|
||||||
setValue={setValue}
|
|
||||||
placeholder=""
|
|
||||||
/>
|
|
||||||
{errors.tags?.specializations && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.tags.specializations.message}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
</header>
|
||||||
</ShowcaseSection>
|
}
|
||||||
</div>
|
footer={<FormFooter />}
|
||||||
|
footerPlacement="form"
|
||||||
<FormFooter />
|
/>
|
||||||
</form>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
|
||||||
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
|
import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
|
||||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
|
||||||
import FormFooter from "@/components/ui/FormFooter";
|
import FormFooter from "@/components/ui/FormFooter";
|
||||||
import { FormErrorMessages } from "@/constants/Texts";
|
import type { TCreateCompanyCategoryCredentials } from "@/lib/api";
|
||||||
import { TCreateCompanyCategoryCredentials } from "@/lib/api";
|
import { createCompanyCategoryFormSections } from "./companyCategoryFormSections";
|
||||||
import useCreateCompanyCategoryPresenter from "./useCreateCompanyCategoryPresenter";
|
import useCreateCompanyCategoryPresenter from "./useCreateCompanyCategoryPresenter";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
@@ -14,72 +13,20 @@ interface IProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CreateCompanyCategoryForm = ({ defaultValues, editMode, id }: IProps) => {
|
const CreateCompanyCategoryForm = ({ defaultValues, editMode, id }: IProps) => {
|
||||||
const { errors, handleSubmit, onSubmit, register } =
|
const { errors, handleSubmit, onSubmit, register, control } =
|
||||||
useCreateCompanyCategoryPresenter({ defaultValues, editMode, id });
|
useCreateCompanyCategoryPresenter({ defaultValues, editMode, id });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<DynamicFormBuilder<TCreateCompanyCategoryCredentials>
|
||||||
className="grid grid-cols-1 gap-9"
|
sections={createCompanyCategoryFormSections(editMode)}
|
||||||
data-cy="company-category-form"
|
register={register}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
control={control}
|
||||||
>
|
errors={errors}
|
||||||
<ShowcaseSection
|
handleSubmit={handleSubmit}
|
||||||
title={editMode ? "Edit Company Category" : "Create Company Category"}
|
onSubmit={onSubmit}
|
||||||
className="grid grid-cols-1 gap-5 md:grid-cols-2"
|
dataCy="company-category-form"
|
||||||
>
|
footer={<FormFooter />}
|
||||||
<div className="flex flex-col gap-2 md:col-span-1">
|
|
||||||
<InputGroup
|
|
||||||
label="Name"
|
|
||||||
required
|
|
||||||
dataCy="company-category-form-name-input"
|
|
||||||
{...register("name", {
|
|
||||||
required: {
|
|
||||||
value: true,
|
|
||||||
message: FormErrorMessages.required,
|
|
||||||
},
|
|
||||||
minLength: {
|
|
||||||
value: 2,
|
|
||||||
message: FormErrorMessages.minLength(2),
|
|
||||||
},
|
|
||||||
maxLength: {
|
|
||||||
value: 100,
|
|
||||||
message: FormErrorMessages.maxLength(100),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
type="text"
|
|
||||||
name="name"
|
|
||||||
placeholder="e.g. Technology"
|
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">{errors.name.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 md:col-span-1">
|
|
||||||
<TextAreaGroup
|
|
||||||
label="Description"
|
|
||||||
dataCy="company-category-form-description-input"
|
|
||||||
{...register("description", {
|
|
||||||
minLength: {
|
|
||||||
value: 10,
|
|
||||||
message: FormErrorMessages.minLength(10),
|
|
||||||
},
|
|
||||||
maxLength: {
|
|
||||||
value: 1000,
|
|
||||||
message: FormErrorMessages.maxLength(1000),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
name="description"
|
|
||||||
/>
|
|
||||||
{errors.description && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.description.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<FormFooter />
|
|
||||||
</div>
|
|
||||||
</ShowcaseSection>
|
|
||||||
</form>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
|
||||||
|
import type { DynamicFormSection } from "@/components/forms/DynamicFormBuilder";
|
||||||
|
import { FormErrorMessages } from "@/constants/Texts";
|
||||||
|
import type { TCreateCompanyCategoryCredentials } from "@/lib/api";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
const companyCategorySectionId = uuidv4();
|
||||||
|
|
||||||
|
export const createCompanyCategoryFormSections = (
|
||||||
|
editMode?: boolean,
|
||||||
|
): DynamicFormSection<TCreateCompanyCategoryCredentials>[] => [
|
||||||
|
{
|
||||||
|
id: companyCategorySectionId,
|
||||||
|
title: editMode ? "Edit Company Category" : "Create Company Category",
|
||||||
|
className: "gap-5",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "name",
|
||||||
|
label: "Name",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "e.g. Technology",
|
||||||
|
required: true,
|
||||||
|
dataCy: "company-category-form-name-input",
|
||||||
|
className: "md:col-span-1",
|
||||||
|
rules: {
|
||||||
|
required: {
|
||||||
|
value: true,
|
||||||
|
message: FormErrorMessages.required,
|
||||||
|
},
|
||||||
|
minLength: {
|
||||||
|
value: 2,
|
||||||
|
message: FormErrorMessages.minLength(2),
|
||||||
|
},
|
||||||
|
maxLength: {
|
||||||
|
value: 100,
|
||||||
|
message: FormErrorMessages.maxLength(100),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "description",
|
||||||
|
className: "md:col-span-1",
|
||||||
|
render: ({ register, errors }) => (
|
||||||
|
<TextAreaGroup<TCreateCompanyCategoryCredentials>
|
||||||
|
label="Description"
|
||||||
|
dataCy="company-category-form-description-input"
|
||||||
|
{...register("description", {
|
||||||
|
minLength: {
|
||||||
|
value: 10,
|
||||||
|
message: FormErrorMessages.minLength(10),
|
||||||
|
},
|
||||||
|
maxLength: {
|
||||||
|
value: 1000,
|
||||||
|
message: FormErrorMessages.maxLength(1000),
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
name="description"
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -20,6 +20,7 @@ const useCreateCompanyCategoryPresenter = ({
|
|||||||
}: IProps) => {
|
}: IProps) => {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<TCreateCompanyCategoryCredentials>({
|
} = useForm<TCreateCompanyCategoryCredentials>({
|
||||||
@@ -38,6 +39,7 @@ const useCreateCompanyCategoryPresenter = ({
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
errors,
|
errors,
|
||||||
|
|||||||
@@ -0,0 +1,686 @@
|
|||||||
|
import { GlobeIcon, MoneyIcon } from "@/assets/icons";
|
||||||
|
import TagInput from "@/components/FormElements/InputGroup/tag-input";
|
||||||
|
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
|
||||||
|
import MultiSelect from "@/components/FormElements/MultiSelect";
|
||||||
|
import { Select } from "@/components/FormElements/select";
|
||||||
|
import type { DynamicFormSection } from "@/components/forms/DynamicFormBuilder";
|
||||||
|
import { REGEX } from "@/constants/regex";
|
||||||
|
import { FormErrorMessages } from "@/constants/Texts";
|
||||||
|
import type { ICreateCompanyCredentials } from "@/lib/api";
|
||||||
|
import type { ILabelValue } from "@/types/shared";
|
||||||
|
import type { UseFormSetValue, UseFormWatch } from "react-hook-form";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import CompanyDocuments from "./documents/CompanyDocuments";
|
||||||
|
import CompanyLinks from "./links/CompanyLinks";
|
||||||
|
|
||||||
|
const companyInformationSectionId = uuidv4();
|
||||||
|
const businessInformationSectionId = uuidv4();
|
||||||
|
const settingsSectionId = uuidv4();
|
||||||
|
const addressSectionId = uuidv4();
|
||||||
|
const tagsSectionId = uuidv4();
|
||||||
|
const documentsSectionId = uuidv4();
|
||||||
|
const linksSectionId = uuidv4();
|
||||||
|
|
||||||
|
interface CompanyFormSectionsOptions {
|
||||||
|
editMode?: boolean;
|
||||||
|
id?: string;
|
||||||
|
categoryItems: ILabelValue[];
|
||||||
|
watch: UseFormWatch<ICreateCompanyCredentials>;
|
||||||
|
setValue: UseFormSetValue<ICreateCompanyCredentials>;
|
||||||
|
persistDocumentFileIds?: (ids: string[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionalNumber = (value: unknown) => {
|
||||||
|
if (value === "" || value === null || value === undefined) return undefined;
|
||||||
|
return Number(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const optionalNumberRange = ({
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
integer = false,
|
||||||
|
}: {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
integer?: boolean;
|
||||||
|
}) => {
|
||||||
|
return (value: unknown) => {
|
||||||
|
const numberValue = optionalNumber(value);
|
||||||
|
|
||||||
|
if (numberValue === undefined) return true;
|
||||||
|
if (!Number.isFinite(numberValue)) return "Enter a valid number";
|
||||||
|
if (integer && !Number.isInteger(numberValue)) {
|
||||||
|
return "This field must be a whole number";
|
||||||
|
}
|
||||||
|
if (numberValue < min) return FormErrorMessages.min(min);
|
||||||
|
if (numberValue > max) return FormErrorMessages.max(max);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const languageItems: ILabelValue[] = [
|
||||||
|
{ value: "en", label: "EN" },
|
||||||
|
{ value: "ar", label: "AR" },
|
||||||
|
{ value: "fr", label: "FR" },
|
||||||
|
{ value: "es", label: "ES" },
|
||||||
|
{ value: "de", label: "DE" },
|
||||||
|
{ value: "zh", label: "ZH" },
|
||||||
|
{ value: "jEna", label: "jEna" },
|
||||||
|
{ value: "ko", label: "KO" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const currencyItems: ILabelValue[] = [
|
||||||
|
{ label: "USD", value: "USD" },
|
||||||
|
{ label: "EUR", value: "EUR" },
|
||||||
|
{ label: "GBP", value: "GBP" },
|
||||||
|
{ label: "JPY", value: "JPY" },
|
||||||
|
{ label: "CAD", value: "CAD" },
|
||||||
|
{ label: "AUD", value: "AUD" },
|
||||||
|
{ label: "CHF", value: "CHF" },
|
||||||
|
{ label: "CNY", value: "CNY" },
|
||||||
|
{ label: "INR", value: "INR" },
|
||||||
|
{ label: "BRL", value: "BRL" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const allTimezones: Record<string, string> = {
|
||||||
|
"Pacific/Midway": "Midway Island, Samoa",
|
||||||
|
"Pacific/Honolulu": "Hawaii",
|
||||||
|
"America/Juneau": "Alaska",
|
||||||
|
"America/Boise": "Mountain Time",
|
||||||
|
"America/Dawson": "Dawson, Yukon",
|
||||||
|
"America/Chihuahua": "Chihuahua, La Paz, Mazatlan",
|
||||||
|
"America/Phoenix": "Arizona",
|
||||||
|
"America/Los_Angeles": "Pacific Time",
|
||||||
|
"America/Chicago": "Central Time",
|
||||||
|
"America/Regina": "Saskatchewan",
|
||||||
|
"America/Mexico_City": "Guadalajara, Mexico City, Monterrey",
|
||||||
|
"America/Belize": "Central America",
|
||||||
|
"America/Detroit": "Eastern Time",
|
||||||
|
"America/Bogota": "Bogota, Lima, Quito",
|
||||||
|
"America/Caracas": "Caracas, La Paz",
|
||||||
|
"America/Santiago": "Santiago",
|
||||||
|
"America/St_Johns": "Newfoundland and Labrador",
|
||||||
|
"America/Sao_Paulo": "Brasilia",
|
||||||
|
"America/Tijuana": "Tijuana",
|
||||||
|
"America/Montevideo": "Montevideo",
|
||||||
|
"America/Argentina/Buenos_Aires": "Buenos Aires, Georgetown",
|
||||||
|
"America/Godthab": "Greenland",
|
||||||
|
"Atlantic/Azores": "Azores",
|
||||||
|
"Atlantic/Cape_Verde": "Cape Verde Islands",
|
||||||
|
GMT: "UTC",
|
||||||
|
"Europe/London": "Edinburgh, London",
|
||||||
|
"Europe/Dublin": "Dublin",
|
||||||
|
"Europe/Lisbon": "Lisbon",
|
||||||
|
"Africa/Casablanca": "Casablanca, Monrovia",
|
||||||
|
"Atlantic/Canary": "Canary Islands",
|
||||||
|
"Europe/Belgrade": "Belgrade, Bratislava, Budapest, Ljubljana, Prague",
|
||||||
|
"Europe/Sarajevo": "Sarajevo, Skopje, Warsaw, Zagreb",
|
||||||
|
"Europe/Brussels": "Brussels, Copenhagen, Madrid, Paris",
|
||||||
|
"Europe/Amsterdam": "Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
|
||||||
|
"Africa/Algiers": "West Central Africa",
|
||||||
|
"Europe/Bucharest": "Bucharest",
|
||||||
|
"Africa/Cairo": "Cairo",
|
||||||
|
"Europe/Helsinki": "Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius",
|
||||||
|
"Europe/Athens": "Athens",
|
||||||
|
"Asia/Jerusalem": "Jerusalem",
|
||||||
|
"Africa/Harare": "Harare, Pretoria",
|
||||||
|
"Europe/Moscow": "Istanbul, Minsk, Moscow, St. Petersburg, Volgograd",
|
||||||
|
"Asia/Kuwait": "Kuwait, Riyadh",
|
||||||
|
"Africa/Nairobi": "Nairobi",
|
||||||
|
"Asia/Baghdad": "Baghdad",
|
||||||
|
"Asia/Tehran": "Tehran",
|
||||||
|
"Asia/Dubai": "Abu Dhabi, Muscat",
|
||||||
|
"Asia/Baku": "Baku, Tbilisi, Yerevan",
|
||||||
|
"Asia/Kabul": "Kabul",
|
||||||
|
"Asia/Yekaterinburg": "Ekaterinburg",
|
||||||
|
"Asia/Karachi": "Islamabad, Karachi, Tashkent",
|
||||||
|
"Asia/Kolkata": "Chennai, Kolkata, Mumbai, New Delhi",
|
||||||
|
"Asia/Kathmandu": "Kathmandu",
|
||||||
|
"Asia/Dhaka": "Astana, Dhaka",
|
||||||
|
"Asia/Colombo": "Sri Jayawardenepura",
|
||||||
|
"Asia/Almaty": "Almaty, Novosibirsk",
|
||||||
|
"Asia/Rangoon": "Yangon Rangoon",
|
||||||
|
"Asia/Bangkok": "Bangkok, Hanoi, Jakarta",
|
||||||
|
"Asia/Krasnoyarsk": "Krasnoyarsk",
|
||||||
|
"Asia/Shanghai": "Beijing, Chongqing, Hong Kong SAR, Urumqi",
|
||||||
|
"Asia/Kuala_Lumpur": "Kuala Lumpur, Singapore",
|
||||||
|
"Asia/Taipei": "Taipei",
|
||||||
|
"Australia/Perth": "Perth",
|
||||||
|
"Asia/Irkutsk": "Irkutsk, Ulaanbaatar",
|
||||||
|
"Asia/Seoul": "Seoul",
|
||||||
|
"Asia/Tokyo": "Osaka, Sapporo, Tokyo",
|
||||||
|
"Asia/Yakutsk": "Yakutsk",
|
||||||
|
"Australia/Darwin": "Darwin",
|
||||||
|
"Australia/Adelaide": "Adelaide",
|
||||||
|
"Australia/Sydney": "Canberra, Melbourne, Sydney",
|
||||||
|
"Australia/Brisbane": "Brisbane",
|
||||||
|
"Australia/Hobart": "Hobart",
|
||||||
|
"Asia/Vladivostok": "Vladivostok",
|
||||||
|
"Pacific/Guam": "Guam, Port Moresby",
|
||||||
|
"Asia/Magadan": "Magadan, Solomon Islands, New Caledonia",
|
||||||
|
"Asia/Kamchatka": "Kamchatka, Marshall Islands",
|
||||||
|
"Pacific/Fiji": "Fiji Islands",
|
||||||
|
"Pacific/Auckland": "Auckland, Wellington",
|
||||||
|
"Pacific/Tongatapu": "Nuku'alofa",
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimezoneOffsetMinutes = (timezone: string, date = new Date()) => {
|
||||||
|
try {
|
||||||
|
const formatter = new Intl.DateTimeFormat("en", {
|
||||||
|
timeZone: timezone,
|
||||||
|
timeZoneName: "shortOffset",
|
||||||
|
});
|
||||||
|
const offset =
|
||||||
|
formatter.formatToParts(date).find((part) => part.type === "timeZoneName")
|
||||||
|
?.value ?? "";
|
||||||
|
const label = offset.replace(/^GMT/, "") || "+0";
|
||||||
|
const match = label.match(/^([+-])(\d{1,2})(?::(\d{2}))?$/);
|
||||||
|
|
||||||
|
if (!match) return 0;
|
||||||
|
|
||||||
|
const sign = match[1] === "-" ? -1 : 1;
|
||||||
|
const hours = Number(match[2]);
|
||||||
|
const minutes = Number(match[3] ?? 0);
|
||||||
|
|
||||||
|
return sign * (hours * 60 + minutes);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimezoneOffset = (offsetMinutes: number) => {
|
||||||
|
const sign = offsetMinutes < 0 ? "-" : "+";
|
||||||
|
const absoluteMinutes = Math.abs(offsetMinutes);
|
||||||
|
const hours = Math.trunc(absoluteMinutes / 60);
|
||||||
|
const minutes = absoluteMinutes % 60;
|
||||||
|
|
||||||
|
return `${sign}${hours}:${minutes.toString().padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasTimezoneDst = (timezone: string) => {
|
||||||
|
const januaryOffset = getTimezoneOffsetMinutes(
|
||||||
|
timezone,
|
||||||
|
new Date(new Date().getFullYear(), 0, 1),
|
||||||
|
);
|
||||||
|
const julyOffset = getTimezoneOffsetMinutes(
|
||||||
|
timezone,
|
||||||
|
new Date(new Date().getFullYear(), 6, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
return januaryOffset !== julyOffset;
|
||||||
|
};
|
||||||
|
|
||||||
|
const timezoneItems: ILabelValue[] = Object.entries(allTimezones)
|
||||||
|
.map(([timezone, name]) => {
|
||||||
|
const offsetMinutes = getTimezoneOffsetMinutes(timezone);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timezone,
|
||||||
|
name,
|
||||||
|
hasDst: hasTimezoneDst(timezone),
|
||||||
|
offsetMinutes,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.offsetMinutes - b.offsetMinutes)
|
||||||
|
.filter((item, index, items) => {
|
||||||
|
return (
|
||||||
|
items.findIndex(
|
||||||
|
(timezone) =>
|
||||||
|
timezone.offsetMinutes === item.offsetMinutes &&
|
||||||
|
timezone.hasDst === item.hasDst,
|
||||||
|
) === index
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map(({ timezone, name, offsetMinutes }) => ({
|
||||||
|
label: `(GMT${formatTimezoneOffset(offsetMinutes)}) ${name}`,
|
||||||
|
value: timezone,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const createCompanyFormSections = ({
|
||||||
|
editMode,
|
||||||
|
id,
|
||||||
|
categoryItems,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
persistDocumentFileIds,
|
||||||
|
}: CompanyFormSectionsOptions): DynamicFormSection<ICreateCompanyCredentials>[] => [
|
||||||
|
{
|
||||||
|
id: companyInformationSectionId,
|
||||||
|
title: "Company information",
|
||||||
|
rootClassName: "lg:col-span-2",
|
||||||
|
className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "name",
|
||||||
|
label: "Name",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "Legal name as registered",
|
||||||
|
required: true,
|
||||||
|
className: "sm:col-span-2",
|
||||||
|
rules: {
|
||||||
|
required: { message: FormErrorMessages.required, value: true },
|
||||||
|
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
|
||||||
|
maxLength: { value: 200, message: FormErrorMessages.maxLength(200) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "email",
|
||||||
|
label: "Email",
|
||||||
|
type: "email",
|
||||||
|
placeholder: "name@company.com",
|
||||||
|
required: true,
|
||||||
|
rules: {
|
||||||
|
required: { message: FormErrorMessages.required, value: true },
|
||||||
|
pattern: {
|
||||||
|
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||||
|
message: FormErrorMessages.invalidEmail,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "phone",
|
||||||
|
label: "Phone",
|
||||||
|
type: "tel",
|
||||||
|
placeholder: "+971 50 123 4567",
|
||||||
|
required: true,
|
||||||
|
rules: {
|
||||||
|
required: { message: FormErrorMessages.required, value: true },
|
||||||
|
minLength: { value: 10, message: FormErrorMessages.minLength(10) },
|
||||||
|
maxLength: { value: 20, message: FormErrorMessages.maxLength(20) },
|
||||||
|
pattern: {
|
||||||
|
value: REGEX.PHONE,
|
||||||
|
message: FormErrorMessages.invalidPattern,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "type",
|
||||||
|
render: ({ register, errors }) => (
|
||||||
|
<Select<ICreateCompanyCredentials>
|
||||||
|
{...register("type", {
|
||||||
|
required: { message: FormErrorMessages.required, value: true },
|
||||||
|
})}
|
||||||
|
errors={errors}
|
||||||
|
name="type"
|
||||||
|
label="Company type"
|
||||||
|
required
|
||||||
|
items={[
|
||||||
|
{ value: "private", label: "Private" },
|
||||||
|
{ value: "public", label: "Public" },
|
||||||
|
{ value: "government", label: "Government" },
|
||||||
|
{ value: "ngo", label: "NGO" },
|
||||||
|
{ value: "startup", label: "Startup" },
|
||||||
|
]}
|
||||||
|
defaultValue="Private"
|
||||||
|
prefixIcon={<GlobeIcon />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "industry",
|
||||||
|
label: "Industry",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "e.g. construction, software, logistics",
|
||||||
|
rules: {
|
||||||
|
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
|
||||||
|
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "registration_number",
|
||||||
|
label: "Registration number",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "Registry or license number",
|
||||||
|
rules: {
|
||||||
|
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
|
||||||
|
maxLength: { value: 50, message: FormErrorMessages.maxLength(50) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "tax_id",
|
||||||
|
label: "Tax id",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "National tax identification number",
|
||||||
|
rules: {
|
||||||
|
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
|
||||||
|
maxLength: { value: 50, message: FormErrorMessages.maxLength(50) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "website",
|
||||||
|
label: "Website",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "https://www.example.com",
|
||||||
|
required: true,
|
||||||
|
className: "sm:col-span-2",
|
||||||
|
rules: {
|
||||||
|
required: { message: FormErrorMessages.required, value: true },
|
||||||
|
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
|
||||||
|
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
|
||||||
|
pattern: {
|
||||||
|
value: REGEX.URL,
|
||||||
|
message: FormErrorMessages.invalidPattern,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "description",
|
||||||
|
className: "sm:col-span-2",
|
||||||
|
render: ({ register, errors }) => (
|
||||||
|
<TextAreaGroup<ICreateCompanyCredentials>
|
||||||
|
label="Description"
|
||||||
|
{...register("description", {
|
||||||
|
minLength: {
|
||||||
|
value: 10,
|
||||||
|
message: FormErrorMessages.minLength(10),
|
||||||
|
},
|
||||||
|
maxLength: {
|
||||||
|
value: 1000,
|
||||||
|
message: FormErrorMessages.maxLength(1000),
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
errors={errors}
|
||||||
|
name="description"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: businessInformationSectionId,
|
||||||
|
title: "Business information",
|
||||||
|
className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "employee_count",
|
||||||
|
label: "Employee count",
|
||||||
|
type: "number",
|
||||||
|
rules: {
|
||||||
|
setValueAs: optionalNumber,
|
||||||
|
validate: optionalNumberRange({ min: 1, max: 100000, integer: true }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "annual_revenue",
|
||||||
|
label: "Annual revenue",
|
||||||
|
type: "number",
|
||||||
|
rules: {
|
||||||
|
setValueAs: optionalNumber,
|
||||||
|
validate: optionalNumberRange({ min: 0, max: 999999999999 }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "founded_year",
|
||||||
|
label: "Founded year",
|
||||||
|
type: "number",
|
||||||
|
className: "sm:col-span-2",
|
||||||
|
rules: {
|
||||||
|
setValueAs: optionalNumber,
|
||||||
|
validate: optionalNumberRange({
|
||||||
|
min: 1800,
|
||||||
|
max: 2100,
|
||||||
|
integer: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: settingsSectionId,
|
||||||
|
title: "Settings",
|
||||||
|
className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "language",
|
||||||
|
render: ({ register, errors }) => (
|
||||||
|
<Select<ICreateCompanyCredentials>
|
||||||
|
{...register("language")}
|
||||||
|
errors={errors}
|
||||||
|
name="language"
|
||||||
|
label="Language"
|
||||||
|
items={languageItems}
|
||||||
|
defaultValue="en"
|
||||||
|
prefixIcon={<GlobeIcon />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "currency",
|
||||||
|
render: ({ register, errors }) => (
|
||||||
|
<Select<ICreateCompanyCredentials>
|
||||||
|
{...register("currency")}
|
||||||
|
errors={errors}
|
||||||
|
name="currency"
|
||||||
|
label="Currency"
|
||||||
|
items={currencyItems}
|
||||||
|
defaultValue="USD"
|
||||||
|
prefixIcon={<MoneyIcon />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "timezone",
|
||||||
|
className: "sm:col-span-2",
|
||||||
|
render: ({ register, errors }) => (
|
||||||
|
<Select<ICreateCompanyCredentials>
|
||||||
|
{...register("timezone")}
|
||||||
|
errors={errors}
|
||||||
|
name="timezone"
|
||||||
|
label="Timezone"
|
||||||
|
items={timezoneItems}
|
||||||
|
value={watch("timezone") ?? ""}
|
||||||
|
placeholder="Select timezone"
|
||||||
|
searchable
|
||||||
|
searchPlaceholder="Search timezone..."
|
||||||
|
clearable
|
||||||
|
onClear={() => setValue("timezone", "")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: addressSectionId,
|
||||||
|
title: "Address",
|
||||||
|
className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "address.street",
|
||||||
|
label: "Street",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "Building, street, suite or unit",
|
||||||
|
className: "sm:col-span-2",
|
||||||
|
rules: {
|
||||||
|
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
|
||||||
|
maxLength: { value: 200, message: FormErrorMessages.maxLength(200) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "address.city",
|
||||||
|
label: "City",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "City or town",
|
||||||
|
rules: {
|
||||||
|
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
|
||||||
|
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "address.state",
|
||||||
|
label: "State",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "State, province, or region",
|
||||||
|
rules: {
|
||||||
|
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
|
||||||
|
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "address.postal_code",
|
||||||
|
label: "Postal code",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "Postal or ZIP code",
|
||||||
|
rules: {
|
||||||
|
minLength: { value: 3, message: FormErrorMessages.minLength(3) },
|
||||||
|
maxLength: { value: 20, message: FormErrorMessages.maxLength(20) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "address.country",
|
||||||
|
label: "Country",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "Full country name",
|
||||||
|
rules: {
|
||||||
|
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
|
||||||
|
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: tagsSectionId,
|
||||||
|
title: "Tags",
|
||||||
|
className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "tags.keywords",
|
||||||
|
render: ({ register }) => (
|
||||||
|
<TagInput<ICreateCompanyCredentials>
|
||||||
|
{...register("tags.keywords")}
|
||||||
|
name="tags.keywords"
|
||||||
|
label="Keywords"
|
||||||
|
placeholder=""
|
||||||
|
register={register}
|
||||||
|
setValue={setValue}
|
||||||
|
watch={watch}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "tags.categories",
|
||||||
|
render: ({ register }) => (
|
||||||
|
<MultiSelect<ICreateCompanyCredentials>
|
||||||
|
{...register("tags.categories")}
|
||||||
|
value={watch("tags.categories")}
|
||||||
|
name="tags.categories"
|
||||||
|
label="Categories"
|
||||||
|
items={categoryItems}
|
||||||
|
placeholder=""
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "tags.cpv_codes",
|
||||||
|
render: ({ register }) => (
|
||||||
|
<TagInput<ICreateCompanyCredentials>
|
||||||
|
{...register("tags.cpv_codes")}
|
||||||
|
label="CPV codes"
|
||||||
|
name="tags.cpv_codes"
|
||||||
|
shouldAddWithSpace
|
||||||
|
watch={watch}
|
||||||
|
setValue={setValue}
|
||||||
|
placeholder=""
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "tags.certifications",
|
||||||
|
render: ({ register }) => (
|
||||||
|
<TagInput<ICreateCompanyCredentials>
|
||||||
|
{...register("tags.certifications")}
|
||||||
|
label="Certifications"
|
||||||
|
name="tags.certifications"
|
||||||
|
watch={watch}
|
||||||
|
setValue={setValue}
|
||||||
|
placeholder=""
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "tags.specializations",
|
||||||
|
className: "sm:col-span-2",
|
||||||
|
render: ({ register, error }) => (
|
||||||
|
<>
|
||||||
|
<TagInput<ICreateCompanyCredentials>
|
||||||
|
{...register("tags.specializations")}
|
||||||
|
label="Specializations"
|
||||||
|
name="tags.specializations"
|
||||||
|
watch={watch}
|
||||||
|
setValue={setValue}
|
||||||
|
placeholder=""
|
||||||
|
/>
|
||||||
|
{error ? (
|
||||||
|
<p className="mt-1.5 text-sm font-medium text-error">{error}</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: documentsSectionId,
|
||||||
|
title: "Documents",
|
||||||
|
rootClassName: "lg:col-span-2",
|
||||||
|
className: "md:grid-cols-1",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "document_file_ids",
|
||||||
|
render: () => (
|
||||||
|
<CompanyDocuments
|
||||||
|
companyId={editMode ? id : undefined}
|
||||||
|
value={watch("document_file_ids") ?? []}
|
||||||
|
onChange={(ids) =>
|
||||||
|
setValue("document_file_ids", ids, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldTouch: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onRemovePersist={persistDocumentFileIds}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: linksSectionId,
|
||||||
|
title: "Links",
|
||||||
|
rootClassName: "lg:col-span-2",
|
||||||
|
className: "md:grid-cols-1",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "links",
|
||||||
|
render: ({ control, errors, register }) => (
|
||||||
|
<CompanyLinks control={control} errors={errors} register={register} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
import { DOCUMENT_FILE_ACCEPT, MAX_DOCUMENT_FILES } from "@/utils/shared";
|
||||||
|
import DocumentItem from "./DocumentItem";
|
||||||
|
import useCompanyDocumentsPresenter from "./useCompanyDocumentsPresenter";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
/** Present in edit mode; switches uploads to the company-documents endpoint. */
|
||||||
|
companyId?: string;
|
||||||
|
value: string[];
|
||||||
|
onChange: (ids: string[]) => void;
|
||||||
|
onRemovePersist?: (ids: string[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CompanyDocuments = ({
|
||||||
|
companyId,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onRemovePersist,
|
||||||
|
}: IProps) => {
|
||||||
|
const {
|
||||||
|
isUploading,
|
||||||
|
isBusy,
|
||||||
|
removingFileId,
|
||||||
|
uploadProgress,
|
||||||
|
failed,
|
||||||
|
errorMessage,
|
||||||
|
onSelectFiles,
|
||||||
|
onRemove,
|
||||||
|
} = useCompanyDocumentsPresenter({
|
||||||
|
companyId,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onRemovePersist,
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputId = "company-documents-input";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<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"
|
||||||
|
multiple
|
||||||
|
accept={DOCUMENT_FILE_ACCEPT}
|
||||||
|
className="sr-only"
|
||||||
|
onChange={onSelectFiles}
|
||||||
|
disabled={isBusy}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
aria-disabled={isBusy}
|
||||||
|
className={`group flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-stroke/80 bg-white/70 px-4 py-9 text-center transition-all duration-300 dark:border-dark-3 dark:bg-dark/20 ${
|
||||||
|
isBusy
|
||||||
|
? "cursor-not-allowed opacity-60"
|
||||||
|
: "cursor-pointer hover:-translate-y-0.5 hover:border-primary hover:bg-primary/[0.03] dark:hover:bg-primary/[0.08]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="shadow-theme-xs mb-3 rounded-2xl bg-primary/[0.12] p-3 text-primary transition-transform duration-300 group-hover:scale-105">
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-dark dark:text-white">
|
||||||
|
Click to upload documents
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-dark-5">
|
||||||
|
PDF, Word, Excel, images, archives — up to 100MB each
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 inline-flex items-center rounded-full border border-primary/20 bg-primary/[0.08] px-3 py-1 text-[11px] font-medium text-primary dark:border-primary/40 dark:bg-primary/[0.18]">
|
||||||
|
Max {MAX_DOCUMENT_FILES} files per upload
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{isUploading ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="text-xs font-medium text-primary">
|
||||||
|
Uploading documents… {companyId ? "" : `${uploadProgress}%`}
|
||||||
|
</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 rounded-full bg-gradient-to-r from-primary to-purple-500 transition-all duration-300"
|
||||||
|
style={{ width: companyId ? "100%" : `${uploadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{errorMessage ? (
|
||||||
|
<p className="text-sm font-medium text-red-500">{errorMessage}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{value.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{value.map((fileId) => (
|
||||||
|
<DocumentItem
|
||||||
|
key={fileId}
|
||||||
|
fileId={fileId}
|
||||||
|
onRemove={onRemove}
|
||||||
|
disabled={isBusy}
|
||||||
|
isRemoving={removingFileId === fileId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-dark-5">No documents attached yet.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{failed.length > 0 ? (
|
||||||
|
<div className="rounded-xl border border-error/35 bg-error/5 p-3">
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-error">
|
||||||
|
Failed uploads
|
||||||
|
</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>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CompanyDocuments;
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
import { formatFileSize } from "@/utils/shared";
|
||||||
|
import useDocumentItemPresenter from "./useDocumentItemPresenter";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
fileId: string;
|
||||||
|
/** 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, 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 ${
|
||||||
|
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
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold text-dark dark:text-white">
|
||||||
|
{isRemoving ? "Removing…" : isLoading ? "Loading…" : info?.filename || "Document"}
|
||||||
|
</p>
|
||||||
|
{info?.size ? (
|
||||||
|
<p className="mt-0.5 text-xs text-dark-5">{formatFileSize(info.size)}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={download}
|
||||||
|
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"}
|
||||||
|
</button>
|
||||||
|
{onRemove ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(fileId)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{isRemoving ? "Removing…" : "Remove"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentItem;
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"use client";
|
||||||
|
import { useUploadCompanyDocuments } from "@/hooks/queries";
|
||||||
|
import { fileService, FailedFile } from "@/lib/api";
|
||||||
|
import { MAX_DOCUMENT_FILES, validateDocumentFile } from "@/utils/shared";
|
||||||
|
import { ChangeEvent, useState } from "react";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
/** Present in edit mode (company exists); drives the upload strategy. */
|
||||||
|
companyId?: string;
|
||||||
|
/** Current document file ids held by the form. */
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the multi-file document upload lifecycle for the company form.
|
||||||
|
*
|
||||||
|
* - Edit mode (companyId set): uploads via `POST /companies/:id/documents`, which
|
||||||
|
* appends + links files server-side and reports per-file failures.
|
||||||
|
* - Create mode (no company yet): uploads via `POST /files/upload/batch` and
|
||||||
|
* appends the returned ids to the form so they're sent with the create call.
|
||||||
|
*/
|
||||||
|
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 ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
const validateSelection = (files: File[]): string | null => {
|
||||||
|
if (value.length + files.length > MAX_DOCUMENT_FILES) {
|
||||||
|
return `You can attach at most ${MAX_DOCUMENT_FILES} documents.`;
|
||||||
|
}
|
||||||
|
for (const file of files) {
|
||||||
|
const error = validateDocumentFile({ file });
|
||||||
|
if (error) return `${file.name}: ${error}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFiles = async (files: File[]) => {
|
||||||
|
if (companyId) {
|
||||||
|
const response = await uploadToCompany(files);
|
||||||
|
onChange(response.data.document_file_ids ?? value);
|
||||||
|
return response.data.failed ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fileService.uploadBatch({
|
||||||
|
files,
|
||||||
|
category: "company_document",
|
||||||
|
onProgress: setUploadProgress,
|
||||||
|
});
|
||||||
|
const newIds = (result.uploaded ?? []).map((item) => item.file_id);
|
||||||
|
onChange([...value, ...newIds]);
|
||||||
|
return result.failed ?? [];
|
||||||
|
};
|
||||||
|
|
||||||
|
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.
|
||||||
|
input.value = "";
|
||||||
|
if (!files.length) return;
|
||||||
|
|
||||||
|
const validationError = validateSelection(files);
|
||||||
|
if (validationError) {
|
||||||
|
setErrorMessage(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorMessage(undefined);
|
||||||
|
setFailed([]);
|
||||||
|
setIsUploading(true);
|
||||||
|
setUploadProgress(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setFailed(await uploadFiles(files));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("ERROR caught while uploading company documents:", error);
|
||||||
|
setErrorMessage("Failed to upload documents. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRemove = async (fileId: string) => {
|
||||||
|
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);
|
||||||
|
} 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,
|
||||||
|
onSelectFiles,
|
||||||
|
onRemove,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCompanyDocumentsPresenter;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
import { useFileInfo } from "@/hooks/queries";
|
||||||
|
import { fileService } from "@/lib/api";
|
||||||
|
import { saveBlobFromAxiosResponse } from "@/utils/shared";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads metadata for a single document file id and exposes a download action.
|
||||||
|
* Keeps the `DocumentItem` view free of data-fetching / side effects.
|
||||||
|
*/
|
||||||
|
const useDocumentItemPresenter = (fileId: string) => {
|
||||||
|
const { data: info, isLoading } = useFileInfo(fileId);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
||||||
|
const download = async () => {
|
||||||
|
setIsDownloading(true);
|
||||||
|
try {
|
||||||
|
const response = await fileService.downloadFile(fileId);
|
||||||
|
saveBlobFromAxiosResponse(response, info?.filename ?? fileId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("ERROR caught while downloading document:", error);
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { info, isLoading, isDownloading, download };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDocumentItemPresenter;
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import InputGroup from "@/components/FormElements/InputGroup";
|
||||||
|
import type { ICreateCompanyCredentials } from "@/lib/api";
|
||||||
|
import type { Control, FieldErrors, UseFormRegister } from "react-hook-form";
|
||||||
|
import { get, useFieldArray } from "react-hook-form";
|
||||||
|
|
||||||
|
const MAX_COMPANY_LINKS = 20;
|
||||||
|
|
||||||
|
interface CompanyLinksProps {
|
||||||
|
control: Control<ICreateCompanyCredentials>;
|
||||||
|
register: UseFormRegister<ICreateCompanyCredentials>;
|
||||||
|
errors: FieldErrors<ICreateCompanyCredentials>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateHttpsUrl = (value?: string) => {
|
||||||
|
const trimmedValue = value?.trim() ?? "";
|
||||||
|
if (!trimmedValue) return "URL is required";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(trimmedValue);
|
||||||
|
return url.protocol === "https:" || "Enter a valid HTTPS URL";
|
||||||
|
} catch {
|
||||||
|
return "Enter a valid HTTPS URL";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const CompanyLinks = ({ control, register, errors }: CompanyLinksProps) => {
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: "links",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{fields.length === 0 ? (
|
||||||
|
<p className="text-sm text-dark-5 dark:text-dark-6">
|
||||||
|
No external links added yet.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fields.map((field, index) => {
|
||||||
|
const urlName = `links.${index}.url` as const;
|
||||||
|
const titleName = `links.${index}.title` as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={field.id}
|
||||||
|
className="grid gap-4 rounded-xl border border-stroke/70 bg-gray-1/50 p-4 dark:border-dark-3 dark:bg-dark-2/50 sm:grid-cols-2"
|
||||||
|
>
|
||||||
|
<InputGroup<ICreateCompanyCredentials>
|
||||||
|
{...register(titleName, {
|
||||||
|
setValueAs: (value: string) => value.trim(),
|
||||||
|
maxLength: {
|
||||||
|
value: 200,
|
||||||
|
message: "Title must be 200 characters or fewer",
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
name={titleName}
|
||||||
|
label="Title (optional)"
|
||||||
|
type="text"
|
||||||
|
placeholder="LinkedIn"
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
|
<InputGroup<ICreateCompanyCredentials>
|
||||||
|
{...register(urlName, {
|
||||||
|
validate: validateHttpsUrl,
|
||||||
|
setValueAs: (value: string) => value.trim(),
|
||||||
|
})}
|
||||||
|
name={urlName}
|
||||||
|
label="URL"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/profile"
|
||||||
|
required
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between sm:col-span-2">
|
||||||
|
<span className="text-xs text-error">
|
||||||
|
{get(errors, `links.${index}.root`)?.message ?? ""}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
className="rounded-lg border border-error/30 px-3 py-1.5 text-sm font-medium text-error transition hover:bg-error/10"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<p className="text-xs text-dark-5 dark:text-dark-6">
|
||||||
|
{fields.length}/{MAX_COMPANY_LINKS} links
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={fields.length >= MAX_COMPANY_LINKS}
|
||||||
|
onClick={() => append({ url: "", title: "" })}
|
||||||
|
className="rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
+ Add link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CompanyLinks;
|
||||||
@@ -20,6 +20,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
const isMutating = useIsMutating();
|
const isMutating = useIsMutating();
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
watch,
|
watch,
|
||||||
@@ -29,6 +30,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
...defaultValues,
|
...defaultValues,
|
||||||
|
links: defaultValues?.links ?? [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,22 +43,43 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
}
|
}
|
||||||
}, [defaultValues, setValue]);
|
}, [defaultValues, setValue]);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: companyCategories } = useCompanyCategoriesQuery({ published: true });
|
const { data: companyCategories } = useCompanyCategoriesQuery({
|
||||||
|
published: true,
|
||||||
|
});
|
||||||
const { mutate: createCompany, isPending: isCreating } = useCreateCompany();
|
const { mutate: createCompany, isPending: isCreating } = useCreateCompany();
|
||||||
const { mutate: updateCompany, isPending: isUpdating } = useUpdateCompany(
|
const { mutate: updateCompany, isPending: isUpdating } = useUpdateCompany(
|
||||||
id as string,
|
id as string,
|
||||||
);
|
);
|
||||||
const onSubmit: SubmitHandler<ICreateCompanyCredentials> = (data) => {
|
const { mutateAsync: updateCompanyDocuments } = useUpdateCompany(
|
||||||
const formattedData = {
|
id as string,
|
||||||
|
{
|
||||||
|
redirectOnSuccess: false,
|
||||||
|
showSuccessToast: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const optionalNumber = (value: unknown) => {
|
||||||
|
if (value === "" || value === null || value === undefined) return undefined;
|
||||||
|
|
||||||
|
return Number(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCompanyData = (
|
||||||
|
data: ICreateCompanyCredentials,
|
||||||
|
documentFileIds = data.document_file_ids,
|
||||||
|
) => ({
|
||||||
...data,
|
...data,
|
||||||
founded_year: data.founded_year ? +data.founded_year : 0,
|
founded_year: optionalNumber(data.founded_year),
|
||||||
employee_count: data.employee_count ? +data.employee_count : 0,
|
employee_count: optionalNumber(data.employee_count),
|
||||||
annual_revenue: data.annual_revenue ? +data.annual_revenue : 0,
|
annual_revenue: optionalNumber(data.annual_revenue),
|
||||||
|
document_file_ids: documentFileIds ?? [],
|
||||||
|
links: (data.links ?? []).map((link) => ({
|
||||||
|
url: link.url.trim(),
|
||||||
|
...(link.title?.trim() ? { title: link.title.trim() } : {}),
|
||||||
|
})),
|
||||||
tags: {
|
tags: {
|
||||||
keywords: data?.tags?.keywords?.length ? data?.tags?.keywords : [],
|
keywords: data?.tags?.keywords?.length ? data?.tags?.keywords : [],
|
||||||
categories: data?.tags?.categories?.length
|
categories: data?.tags?.categories?.length ? data?.tags?.categories : [],
|
||||||
? data?.tags?.categories
|
|
||||||
: [],
|
|
||||||
cpv_codes: data?.tags?.cpv_codes?.length ? data?.tags?.cpv_codes : [],
|
cpv_codes: data?.tags?.cpv_codes?.length ? data?.tags?.cpv_codes : [],
|
||||||
specializations: data?.tags?.specializations?.length
|
specializations: data?.tags?.specializations?.length
|
||||||
? data?.tags?.specializations
|
? data?.tags?.specializations
|
||||||
@@ -65,7 +88,10 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
? data?.tags?.certifications
|
? data?.tags?.certifications
|
||||||
: [],
|
: [],
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
|
const onSubmit: SubmitHandler<ICreateCompanyCredentials> = (data) => {
|
||||||
|
const formattedData = formatCompanyData(data);
|
||||||
if (editMode) {
|
if (editMode) {
|
||||||
updateCompany({
|
updateCompany({
|
||||||
id: id as string,
|
id: id as string,
|
||||||
@@ -74,7 +100,18 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
} else {
|
} else {
|
||||||
createCompany(formattedData as ICreateCompanyCredentials);
|
createCompany(formattedData as ICreateCompanyCredentials);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistDocumentFileIds = async (documentFileIds: string[]) => {
|
||||||
|
if (!editMode || !id) return;
|
||||||
|
|
||||||
|
await updateCompanyDocuments({
|
||||||
|
id,
|
||||||
|
credentials: formatCompanyData(
|
||||||
|
watch() as ICreateCompanyCredentials,
|
||||||
|
documentFileIds,
|
||||||
|
) as ICreateCompanyCredentials,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
@@ -84,6 +121,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
return {
|
return {
|
||||||
errors,
|
errors,
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
handleCancel,
|
handleCancel,
|
||||||
@@ -93,6 +131,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
router,
|
router,
|
||||||
watch,
|
watch,
|
||||||
setValue,
|
setValue,
|
||||||
|
persistDocumentFileIds,
|
||||||
companyCategories,
|
companyCategories,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { EyeIcon, GlobeIcon } from "@/assets/icons";
|
import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
|
||||||
import MultiSelect from "@/components/FormElements/MultiSelect";
|
|
||||||
import {
|
|
||||||
createPhoneFieldRules,
|
|
||||||
PhoneNumberInput,
|
|
||||||
} from "@/components/FormElements/PhoneNumberInput";
|
|
||||||
import { Select } from "@/components/FormElements/select";
|
|
||||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
|
||||||
import FormFooter from "@/components/ui/FormFooter";
|
import FormFooter from "@/components/ui/FormFooter";
|
||||||
import { AdminRoles } from "@/constants/enums";
|
import type { CreateCustomerCredentials } from "@/lib/api";
|
||||||
import { REGEX } from "@/constants/regex";
|
import { createCustomerFormSections } from "./customerFormSections";
|
||||||
import { FormErrorMessages } from "@/constants/Texts";
|
|
||||||
import { CreateCustomerCredentials } from "@/lib/api";
|
|
||||||
import { getEnumAsArray } from "@/utils/shared";
|
|
||||||
import { Controller } from "react-hook-form";
|
|
||||||
import useCreateCustomerPresenter from "./useCreateCustomerPresenter";
|
import useCreateCustomerPresenter from "./useCreateCustomerPresenter";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
@@ -37,202 +25,28 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
setShowPassword,
|
setShowPassword,
|
||||||
} = useCreateCustomerPresenter({ defaultValues, editMode, id });
|
} = useCreateCustomerPresenter({ defaultValues, editMode, id });
|
||||||
|
|
||||||
|
const companyItems =
|
||||||
|
companies?.map((company) => ({
|
||||||
|
value: company.id,
|
||||||
|
label: company.name,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="grid grid-cols-1 gap-9" onSubmit={handleSubmit(onSubmit)}>
|
<DynamicFormBuilder<CreateCustomerCredentials>
|
||||||
<ShowcaseSection
|
sections={createCustomerFormSections({
|
||||||
title="Create Customer"
|
editMode,
|
||||||
className="grid grid-cols-1 gap-5 md:grid-cols-2"
|
companyItems,
|
||||||
>
|
watch,
|
||||||
<div className="flex flex-col gap-2">
|
showPassword,
|
||||||
<InputGroup
|
setShowPassword,
|
||||||
{...register("username", {
|
|
||||||
required: { value: true, message: FormErrorMessages.required },
|
|
||||||
minLength: { value: 3, message: FormErrorMessages.minLength(3) },
|
|
||||||
maxLength: {
|
|
||||||
value: 30,
|
|
||||||
message: FormErrorMessages.maxLength(30),
|
|
||||||
},
|
|
||||||
pattern: {
|
|
||||||
value: REGEX.USERNAME,
|
|
||||||
message: FormErrorMessages.invalidPattern,
|
|
||||||
},
|
|
||||||
})}
|
})}
|
||||||
name="username"
|
register={register}
|
||||||
label="Username"
|
|
||||||
type="text"
|
|
||||||
placeholder="Unique login, letters and numbers only"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{errors.username && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.username.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<MultiSelect
|
|
||||||
label="Select companies"
|
|
||||||
value={watch("companies")}
|
|
||||||
items={
|
|
||||||
companies
|
|
||||||
? companies?.map((item) => ({
|
|
||||||
value: item.id,
|
|
||||||
label: item.name,
|
|
||||||
}))
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
{...register("companies")}
|
|
||||||
errors={errors}
|
|
||||||
placeholder=""
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<InputGroup
|
|
||||||
{...register("email", {
|
|
||||||
required: { value: true, message: FormErrorMessages.required },
|
|
||||||
pattern: {
|
|
||||||
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
||||||
message: FormErrorMessages.invalidEmail,
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
name="email"
|
|
||||||
label="Email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
placeholder="name@company.com"
|
|
||||||
/>
|
|
||||||
{errors.email && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">{errors.email.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<Select
|
|
||||||
label="Role"
|
|
||||||
{...register("role", {
|
|
||||||
required: { message: FormErrorMessages.required, value: true },
|
|
||||||
})}
|
|
||||||
name="role"
|
|
||||||
value={watch("role")}
|
|
||||||
items={getEnumAsArray(AdminRoles)}
|
|
||||||
placeholder="Choose a role"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{!editMode && (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<InputGroup
|
|
||||||
{...register("password", {
|
|
||||||
required: { value: true, message: FormErrorMessages.required },
|
|
||||||
minLength: {
|
|
||||||
value: 8,
|
|
||||||
message: FormErrorMessages.minLength(8),
|
|
||||||
},
|
|
||||||
maxLength: {
|
|
||||||
value: 128,
|
|
||||||
message: FormErrorMessages.maxLength(128),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
disabled={editMode}
|
|
||||||
name="password"
|
|
||||||
icon={
|
|
||||||
<button
|
|
||||||
className="absolute right-4.5 top-1/2 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-dark-5 transition hover:text-primary dark:text-white/75 [&>svg]:!static [&>svg]:!size-5 [&>svg]:!translate-y-0"
|
|
||||||
onClick={() => {
|
|
||||||
setShowPassword(!showPassword);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EyeIcon />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
iconPosition="right"
|
|
||||||
label="Password"
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
placeholder="At least 8 characters"
|
|
||||||
required={!editMode}
|
|
||||||
/>
|
|
||||||
{errors.password && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.password.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<InputGroup
|
|
||||||
{...register("full_name", {
|
|
||||||
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
|
|
||||||
maxLength: {
|
|
||||||
value: 100,
|
|
||||||
message: FormErrorMessages.maxLength(100),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
name="full_name"
|
|
||||||
label="Full name"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
{errors.full_name && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.full_name.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Controller
|
|
||||||
name="phone"
|
|
||||||
control={control}
|
control={control}
|
||||||
rules={createPhoneFieldRules({ required: false })}
|
errors={errors}
|
||||||
render={({ field }) => (
|
handleSubmit={handleSubmit}
|
||||||
<PhoneNumberInput
|
onSubmit={onSubmit}
|
||||||
label="Phone"
|
footer={<FormFooter />}
|
||||||
value={field.value ?? ""}
|
|
||||||
onChange={field.onChange}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
error={errors.phone?.message as string | undefined}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Select
|
|
||||||
{...register("type", {
|
|
||||||
required: {
|
|
||||||
message: FormErrorMessages.required,
|
|
||||||
value: true,
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
name="type"
|
|
||||||
label="Type"
|
|
||||||
required
|
|
||||||
value={watch("type")}
|
|
||||||
placeholder="Choose customer type"
|
|
||||||
prefixIcon={<GlobeIcon />}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: "Individual",
|
|
||||||
value: "individual",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Company",
|
|
||||||
value: "company",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Government",
|
|
||||||
value: "government",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{errors.type && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">{errors.type.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<FormFooter />
|
|
||||||
</div>
|
|
||||||
</ShowcaseSection>
|
|
||||||
</form>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { EyeIcon, GlobeIcon } from "@/assets/icons";
|
||||||
|
import MultiSelect from "@/components/FormElements/MultiSelect";
|
||||||
|
import {
|
||||||
|
createPhoneFieldRules,
|
||||||
|
PhoneNumberInput,
|
||||||
|
} from "@/components/FormElements/PhoneNumberInput";
|
||||||
|
import { Select } from "@/components/FormElements/select";
|
||||||
|
import type { DynamicFormSection } from "@/components/forms/DynamicFormBuilder";
|
||||||
|
import { AdminRoles } from "@/constants/enums";
|
||||||
|
import { REGEX } from "@/constants/regex";
|
||||||
|
import { FormErrorMessages } from "@/constants/Texts";
|
||||||
|
import type { CreateCustomerCredentials } from "@/lib/api";
|
||||||
|
import type { ILabelValue } from "@/types/shared";
|
||||||
|
import { getEnumAsArray } from "@/utils/shared";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import { Controller, type UseFormWatch } from "react-hook-form";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
const customerSectionId = uuidv4();
|
||||||
|
|
||||||
|
interface CustomerFormSectionsOptions {
|
||||||
|
editMode?: boolean;
|
||||||
|
companyItems: ILabelValue[];
|
||||||
|
watch: UseFormWatch<CreateCustomerCredentials>;
|
||||||
|
showPassword: boolean;
|
||||||
|
setShowPassword: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createCustomerFormSections = ({
|
||||||
|
editMode,
|
||||||
|
companyItems,
|
||||||
|
watch,
|
||||||
|
showPassword,
|
||||||
|
setShowPassword,
|
||||||
|
}: CustomerFormSectionsOptions): DynamicFormSection<CreateCustomerCredentials>[] => [
|
||||||
|
{
|
||||||
|
id: customerSectionId,
|
||||||
|
title: "Create Customer",
|
||||||
|
className: "gap-5",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "username",
|
||||||
|
label: "Username",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "Unique login, letters and numbers only",
|
||||||
|
required: true,
|
||||||
|
rules: {
|
||||||
|
required: { value: true, message: FormErrorMessages.required },
|
||||||
|
minLength: { value: 3, message: FormErrorMessages.minLength(3) },
|
||||||
|
maxLength: { value: 30, message: FormErrorMessages.maxLength(30) },
|
||||||
|
pattern: {
|
||||||
|
value: REGEX.USERNAME,
|
||||||
|
message: FormErrorMessages.invalidPattern,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "companies",
|
||||||
|
render: ({ register, errors }) => (
|
||||||
|
<MultiSelect<CreateCustomerCredentials>
|
||||||
|
label="Select companies"
|
||||||
|
value={watch("companies")}
|
||||||
|
items={companyItems}
|
||||||
|
{...register("companies")}
|
||||||
|
errors={errors}
|
||||||
|
placeholder=""
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "email",
|
||||||
|
label: "Email",
|
||||||
|
type: "email",
|
||||||
|
placeholder: "name@company.com",
|
||||||
|
required: true,
|
||||||
|
rules: {
|
||||||
|
required: { value: true, message: FormErrorMessages.required },
|
||||||
|
pattern: {
|
||||||
|
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||||
|
message: FormErrorMessages.invalidEmail,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "role",
|
||||||
|
render: ({ register, errors }) => (
|
||||||
|
<Select<CreateCustomerCredentials>
|
||||||
|
label="Role"
|
||||||
|
{...register("role", {
|
||||||
|
required: { message: FormErrorMessages.required, value: true },
|
||||||
|
})}
|
||||||
|
errors={errors}
|
||||||
|
name="role"
|
||||||
|
value={watch("role")}
|
||||||
|
items={getEnumAsArray(AdminRoles)}
|
||||||
|
placeholder="Choose a role"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "password",
|
||||||
|
label: "Password",
|
||||||
|
type: showPassword ? "text" : "password",
|
||||||
|
placeholder: "At least 8 characters",
|
||||||
|
required: !editMode,
|
||||||
|
disabled: editMode,
|
||||||
|
hidden: editMode,
|
||||||
|
iconPosition: "right",
|
||||||
|
icon: (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-4.5 top-1/2 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-dark-5 transition hover:text-primary dark:text-white/75 [&>svg]:!static [&>svg]:!size-5 [&>svg]:!translate-y-0"
|
||||||
|
onClick={() => setShowPassword((visible) => !visible)}
|
||||||
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
|
>
|
||||||
|
<EyeIcon />
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
rules: {
|
||||||
|
required: { value: true, message: FormErrorMessages.required },
|
||||||
|
minLength: { value: 8, message: FormErrorMessages.minLength(8) },
|
||||||
|
maxLength: { value: 128, message: FormErrorMessages.maxLength(128) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "full_name",
|
||||||
|
label: "Full name",
|
||||||
|
type: "text",
|
||||||
|
rules: {
|
||||||
|
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
|
||||||
|
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "phone",
|
||||||
|
render: ({ control, errors }) => (
|
||||||
|
<Controller
|
||||||
|
name="phone"
|
||||||
|
control={control}
|
||||||
|
rules={createPhoneFieldRules({ required: false })}
|
||||||
|
render={({ field }) => (
|
||||||
|
<PhoneNumberInput
|
||||||
|
label="Phone"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
error={errors.phone?.message as string | undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "type",
|
||||||
|
render: ({ register, errors }) => (
|
||||||
|
<Select<CreateCustomerCredentials>
|
||||||
|
{...register("type", {
|
||||||
|
required: {
|
||||||
|
message: FormErrorMessages.required,
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
errors={errors}
|
||||||
|
name="type"
|
||||||
|
label="Type"
|
||||||
|
required
|
||||||
|
value={watch("type")}
|
||||||
|
placeholder="Choose customer type"
|
||||||
|
prefixIcon={<GlobeIcon />}
|
||||||
|
items={[
|
||||||
|
{ label: "Individual", value: "individual" },
|
||||||
|
{ label: "Company", value: "company" },
|
||||||
|
{ label: "Government", value: "government" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
|
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
|
||||||
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
|
|
||||||
import MultiSelect from "@/components/FormElements/MultiSelect";
|
|
||||||
import { Select } from "@/components/FormElements/select";
|
|
||||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
|
||||||
import FormFooter from "@/components/ui/FormFooter";
|
import FormFooter from "@/components/ui/FormFooter";
|
||||||
import { REGEX } from "@/constants/regex";
|
import type { TCreateNotificationCredentials } from "@/lib/api/types/NotificationHistory";
|
||||||
import { FormErrorMessages } from "@/constants/Texts";
|
import { createNotificationFormSections } from "./notificationFormSections";
|
||||||
import useCreateNotificationFormPresenter from "./useCreateNotificationFormPresenter";
|
import useCreateNotificationFormPresenter from "./useCreateNotificationFormPresenter";
|
||||||
|
|
||||||
const CreateNotificationForm = () => {
|
const CreateNotificationForm = () => {
|
||||||
@@ -29,157 +25,29 @@ const CreateNotificationForm = () => {
|
|||||||
types,
|
types,
|
||||||
channels,
|
channels,
|
||||||
} = useCreateNotificationFormPresenter();
|
} = useCreateNotificationFormPresenter();
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<ShowcaseSection
|
|
||||||
title="Create Notification"
|
|
||||||
className="grid grid-cols-1 gap-5 md:grid-cols-2"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<InputGroup
|
|
||||||
required
|
|
||||||
{...register("title", {
|
|
||||||
required: { value: true, message: FormErrorMessages.required },
|
|
||||||
})}
|
|
||||||
name="title"
|
|
||||||
label="Title"
|
|
||||||
type="text"
|
|
||||||
placeholder="Short headline shown to recipients"
|
|
||||||
/>
|
|
||||||
{errors.title && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">{errors.title.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<DatePicker
|
|
||||||
control={control}
|
|
||||||
name="schedule_at"
|
|
||||||
label="schedule at"
|
|
||||||
range={false}
|
|
||||||
timePicker
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Select
|
|
||||||
required
|
|
||||||
{...register("target", {
|
|
||||||
required: {
|
|
||||||
value: true,
|
|
||||||
message: FormErrorMessages.required,
|
|
||||||
},
|
|
||||||
onChange: onSelectTarget,
|
|
||||||
})}
|
|
||||||
items={targets}
|
|
||||||
label="target"
|
|
||||||
placeholder="Choose who this applies to"
|
|
||||||
name="target"
|
|
||||||
/>
|
|
||||||
{errors.target && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">{errors.target.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
return (
|
||||||
<MultiSelect
|
<DynamicFormBuilder<TCreateNotificationCredentials>
|
||||||
disabled={!recipient}
|
sections={createNotificationFormSections({
|
||||||
required={!!recipient}
|
recipient,
|
||||||
{...register("recipient")}
|
onSelectTarget,
|
||||||
label="recipient"
|
onSearchRecipient,
|
||||||
items={recipient ?? []}
|
onLoadMoreRecipients,
|
||||||
placeholder={
|
hasMoreRecipients,
|
||||||
recipient && recipient.length > 0 ? "Choose recipients" : ""
|
isLoadingMoreRecipients,
|
||||||
}
|
isLoadingRecipients,
|
||||||
name="recipient"
|
priorities,
|
||||||
onSearch={recipient ? onSearchRecipient : undefined}
|
targets,
|
||||||
onLoadMore={onLoadMoreRecipients}
|
types,
|
||||||
hasMore={hasMoreRecipients}
|
channels,
|
||||||
isLoading={isLoadingRecipients}
|
|
||||||
isLoadingMore={isLoadingMoreRecipients}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<MultiSelect
|
|
||||||
required
|
|
||||||
{...register("channels")}
|
|
||||||
label="channels"
|
|
||||||
items={channels ?? []}
|
|
||||||
placeholder="Email, push, or both"
|
|
||||||
name="channels"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<InputGroup
|
|
||||||
{...register("link", {
|
|
||||||
pattern: {
|
|
||||||
value: REGEX.URL,
|
|
||||||
message: FormErrorMessages.invalidPattern,
|
|
||||||
},
|
|
||||||
})}
|
})}
|
||||||
name="link"
|
register={register}
|
||||||
label="Link"
|
control={control}
|
||||||
type="url"
|
errors={errors}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
footer={<FormFooter />}
|
||||||
/>
|
/>
|
||||||
{errors.link && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">{errors.link.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Select
|
|
||||||
required
|
|
||||||
{...register("priority", {
|
|
||||||
required: {
|
|
||||||
value: true,
|
|
||||||
message: FormErrorMessages.required,
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
items={priorities}
|
|
||||||
label="Priority"
|
|
||||||
placeholder="Choose urgency"
|
|
||||||
name="priority"
|
|
||||||
/>
|
|
||||||
{errors.priority && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.priority.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Select
|
|
||||||
required
|
|
||||||
{...register("type", {
|
|
||||||
required: {
|
|
||||||
value: true,
|
|
||||||
message: FormErrorMessages.required,
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
items={types}
|
|
||||||
label="type"
|
|
||||||
placeholder="Info, warning, alert, or reject"
|
|
||||||
name="type"
|
|
||||||
/>
|
|
||||||
{errors.type && (
|
|
||||||
<p className="mt-1 text-sm text-red-500">{errors.type.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 md:col-span-2">
|
|
||||||
<TextAreaGroup
|
|
||||||
required
|
|
||||||
{...register("description", {
|
|
||||||
required: {
|
|
||||||
value: true,
|
|
||||||
message: FormErrorMessages.required,
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
label="Description"
|
|
||||||
name="description"
|
|
||||||
placeholder="Main message — include context and any action needed"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<FormFooter />
|
|
||||||
</div>
|
|
||||||
</ShowcaseSection>
|
|
||||||
</form>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
|
||||||
|
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
|
||||||
|
import MultiSelect from "@/components/FormElements/MultiSelect";
|
||||||
|
import { Select } from "@/components/FormElements/select";
|
||||||
|
import type { DynamicFormSection } from "@/components/forms/DynamicFormBuilder";
|
||||||
|
import { REGEX } from "@/constants/regex";
|
||||||
|
import { FormErrorMessages } from "@/constants/Texts";
|
||||||
|
import type { TCreateNotificationCredentials } from "@/lib/api/types/NotificationHistory";
|
||||||
|
import type { ILabelValue } from "@/types/shared";
|
||||||
|
import type { ChangeEvent } from "react";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
const notificationSectionId = uuidv4();
|
||||||
|
|
||||||
|
interface NotificationFormSectionsOptions {
|
||||||
|
recipient: ILabelValue[] | null;
|
||||||
|
onSelectTarget: (event: ChangeEvent<HTMLSelectElement>) => void;
|
||||||
|
onSearchRecipient: (query: string) => void;
|
||||||
|
onLoadMoreRecipients: () => void;
|
||||||
|
hasMoreRecipients: boolean;
|
||||||
|
isLoadingMoreRecipients: boolean;
|
||||||
|
isLoadingRecipients: boolean;
|
||||||
|
priorities: ILabelValue[];
|
||||||
|
targets: ILabelValue[];
|
||||||
|
types: ILabelValue[];
|
||||||
|
channels: ILabelValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createNotificationFormSections = ({
|
||||||
|
recipient,
|
||||||
|
onSelectTarget,
|
||||||
|
onSearchRecipient,
|
||||||
|
onLoadMoreRecipients,
|
||||||
|
hasMoreRecipients,
|
||||||
|
isLoadingMoreRecipients,
|
||||||
|
isLoadingRecipients,
|
||||||
|
priorities,
|
||||||
|
targets,
|
||||||
|
types,
|
||||||
|
channels,
|
||||||
|
}: NotificationFormSectionsOptions): DynamicFormSection<TCreateNotificationCredentials>[] => [
|
||||||
|
{
|
||||||
|
id: notificationSectionId,
|
||||||
|
title: "Create Notification",
|
||||||
|
className: "gap-5",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "title",
|
||||||
|
label: "Title",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "Short headline shown to recipients",
|
||||||
|
required: true,
|
||||||
|
rules: {
|
||||||
|
required: { value: true, message: FormErrorMessages.required },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "schedule_at",
|
||||||
|
render: ({ control }) => (
|
||||||
|
<DatePicker
|
||||||
|
control={control}
|
||||||
|
name="schedule_at"
|
||||||
|
label="schedule at"
|
||||||
|
range={false}
|
||||||
|
timePicker
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "target",
|
||||||
|
render: ({ register, errors }) => (
|
||||||
|
<Select<TCreateNotificationCredentials>
|
||||||
|
required
|
||||||
|
{...register("target", {
|
||||||
|
required: {
|
||||||
|
value: true,
|
||||||
|
message: FormErrorMessages.required,
|
||||||
|
},
|
||||||
|
onChange: onSelectTarget,
|
||||||
|
})}
|
||||||
|
errors={errors}
|
||||||
|
items={targets}
|
||||||
|
label="target"
|
||||||
|
placeholder="Choose who this applies to"
|
||||||
|
name="target"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "recipient",
|
||||||
|
render: ({ register, errors }) => (
|
||||||
|
<MultiSelect<TCreateNotificationCredentials>
|
||||||
|
disabled={!recipient}
|
||||||
|
required={!!recipient}
|
||||||
|
{...register("recipient")}
|
||||||
|
errors={errors}
|
||||||
|
label="recipient"
|
||||||
|
items={recipient ?? []}
|
||||||
|
placeholder={
|
||||||
|
recipient && recipient.length > 0 ? "Choose recipients" : ""
|
||||||
|
}
|
||||||
|
name="recipient"
|
||||||
|
onSearch={recipient ? onSearchRecipient : undefined}
|
||||||
|
onLoadMore={onLoadMoreRecipients}
|
||||||
|
hasMore={hasMoreRecipients}
|
||||||
|
isLoading={isLoadingRecipients}
|
||||||
|
isLoadingMore={isLoadingMoreRecipients}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "channels",
|
||||||
|
render: ({ register, errors }) => (
|
||||||
|
<MultiSelect<TCreateNotificationCredentials>
|
||||||
|
required
|
||||||
|
{...register("channels")}
|
||||||
|
errors={errors}
|
||||||
|
label="channels"
|
||||||
|
items={channels ?? []}
|
||||||
|
placeholder="Email, push, or both"
|
||||||
|
name="channels"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "input",
|
||||||
|
name: "link",
|
||||||
|
label: "Link",
|
||||||
|
type: "url",
|
||||||
|
rules: {
|
||||||
|
pattern: {
|
||||||
|
value: REGEX.URL,
|
||||||
|
message: FormErrorMessages.invalidPattern,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "priority",
|
||||||
|
render: ({ register, errors }) => (
|
||||||
|
<Select<TCreateNotificationCredentials>
|
||||||
|
required
|
||||||
|
{...register("priority", {
|
||||||
|
required: {
|
||||||
|
value: true,
|
||||||
|
message: FormErrorMessages.required,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
errors={errors}
|
||||||
|
items={priorities}
|
||||||
|
label="Priority"
|
||||||
|
placeholder="Choose urgency"
|
||||||
|
name="priority"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "type",
|
||||||
|
render: ({ register, errors }) => (
|
||||||
|
<Select<TCreateNotificationCredentials>
|
||||||
|
required
|
||||||
|
{...register("type", {
|
||||||
|
required: {
|
||||||
|
value: true,
|
||||||
|
message: FormErrorMessages.required,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
errors={errors}
|
||||||
|
items={types}
|
||||||
|
label="type"
|
||||||
|
placeholder="Info, warning, alert, or reject"
|
||||||
|
name="type"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "custom",
|
||||||
|
name: "description",
|
||||||
|
className: "md:col-span-2",
|
||||||
|
render: ({ register, errors }) => (
|
||||||
|
<TextAreaGroup<TCreateNotificationCredentials>
|
||||||
|
required
|
||||||
|
{...register("description", {
|
||||||
|
required: {
|
||||||
|
value: true,
|
||||||
|
message: FormErrorMessages.required,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
errors={errors}
|
||||||
|
label="Description"
|
||||||
|
name="description"
|
||||||
|
placeholder="Main message — include context and any action needed"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -4,8 +4,9 @@ export function Logo() {
|
|||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
src="/images/main-logo.svg"
|
src="/images/main-logo.svg"
|
||||||
width={174}
|
width={1217}
|
||||||
height={32}
|
height={192}
|
||||||
|
className="h-auto w-[174px]"
|
||||||
alt="Opp lens logo"
|
alt="Opp lens logo"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
quality={100}
|
quality={100}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ interface FileImageUploadCardProps {
|
|||||||
label: string;
|
label: string;
|
||||||
inputId: string;
|
inputId: string;
|
||||||
accept?: string;
|
accept?: string;
|
||||||
|
title?: string;
|
||||||
|
hint?: string;
|
||||||
|
recommendation?: string;
|
||||||
|
fallbackFileName?: string;
|
||||||
previewUrl?: string | null;
|
previewUrl?: string | null;
|
||||||
selectedFileName?: string;
|
selectedFileName?: string;
|
||||||
selectedFileSize?: number | null;
|
selectedFileSize?: number | null;
|
||||||
@@ -22,6 +26,10 @@ const FileImageUploadCard = ({
|
|||||||
label,
|
label,
|
||||||
inputId,
|
inputId,
|
||||||
accept = "image/png,image/jpeg,image/jpg,image/webp",
|
accept = "image/png,image/jpeg,image/jpg,image/webp",
|
||||||
|
title = "Click to upload your profile image",
|
||||||
|
hint = "PNG, JPG, WEBP up to 5MB",
|
||||||
|
recommendation = "Recommended size: 400 x 400",
|
||||||
|
fallbackFileName = "Current profile image",
|
||||||
previewUrl,
|
previewUrl,
|
||||||
selectedFileName,
|
selectedFileName,
|
||||||
selectedFileSize,
|
selectedFileSize,
|
||||||
@@ -68,11 +76,11 @@ const FileImageUploadCard = ({
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-semibold text-dark dark:text-white">
|
<p className="text-sm font-semibold text-dark dark:text-white">
|
||||||
Click to upload your profile image
|
{title}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-dark-5">PNG, JPG, WEBP up to 5MB</p>
|
<p className="mt-1 text-xs text-dark-5">{hint}</p>
|
||||||
<div className="mt-4 inline-flex items-center rounded-full border border-primary/20 bg-primary/[0.08] px-3 py-1 text-[11px] font-medium text-primary dark:border-primary/40 dark:bg-primary/[0.18]">
|
<div className="mt-4 inline-flex items-center rounded-full border border-primary/20 bg-primary/[0.08] px-3 py-1 text-[11px] font-medium text-primary dark:border-primary/40 dark:bg-primary/[0.18]">
|
||||||
Recommended size: 400 x 400
|
{recommendation}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
) : (
|
) : (
|
||||||
@@ -97,7 +105,7 @@ const FileImageUploadCard = ({
|
|||||||
Ready
|
Ready
|
||||||
</div>
|
</div>
|
||||||
<p className="truncate text-sm font-semibold text-dark dark:text-white">
|
<p className="truncate text-sm font-semibold text-dark dark:text-white">
|
||||||
{selectedFileName || "Current profile image"}
|
{selectedFileName || fallbackFileName}
|
||||||
</p>
|
</p>
|
||||||
{selectedFileSize ? (
|
{selectedFileSize ? (
|
||||||
<p className="mt-0.5 text-xs text-dark-5">
|
<p className="mt-0.5 text-xs text-dark-5">
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import FileImageUploadCard from "@/components/ui/FileImageUploadCard";
|
||||||
|
import { useImageUploadFieldPresenter } from "@/components/ui/useImageUploadFieldPresenter";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
value?: string;
|
||||||
|
onChange: (url: string) => void;
|
||||||
|
label: string;
|
||||||
|
inputId: string;
|
||||||
|
accept?: string;
|
||||||
|
title?: string;
|
||||||
|
hint?: string;
|
||||||
|
recommendation?: string;
|
||||||
|
fallbackFileName?: string;
|
||||||
|
category?: string;
|
||||||
|
tags?: string[];
|
||||||
|
description?: string;
|
||||||
|
allowedTypes?: string[];
|
||||||
|
maxSize?: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controlled image upload field: renders the shared `FileImageUploadCard` and
|
||||||
|
* owns the upload lifecycle via `useImageUploadFieldPresenter`. Stores the
|
||||||
|
* resulting file URL through `onChange`.
|
||||||
|
*/
|
||||||
|
const ImageUploadField = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
inputId,
|
||||||
|
accept,
|
||||||
|
title,
|
||||||
|
hint,
|
||||||
|
recommendation,
|
||||||
|
fallbackFileName,
|
||||||
|
category,
|
||||||
|
tags,
|
||||||
|
description,
|
||||||
|
allowedTypes,
|
||||||
|
maxSize,
|
||||||
|
errorMessage,
|
||||||
|
}: IProps) => {
|
||||||
|
const {
|
||||||
|
fileRegister,
|
||||||
|
isUploading,
|
||||||
|
uploadProgress,
|
||||||
|
selectedFileName,
|
||||||
|
selectedFileSize,
|
||||||
|
previewUrl,
|
||||||
|
errorMessage: uploadError,
|
||||||
|
clear,
|
||||||
|
} = useImageUploadFieldPresenter({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
category,
|
||||||
|
tags,
|
||||||
|
description,
|
||||||
|
allowedTypes,
|
||||||
|
maxSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileImageUploadCard
|
||||||
|
label={label}
|
||||||
|
inputId={inputId}
|
||||||
|
accept={accept}
|
||||||
|
title={title}
|
||||||
|
hint={hint}
|
||||||
|
recommendation={recommendation}
|
||||||
|
fallbackFileName={fallbackFileName}
|
||||||
|
previewUrl={previewUrl}
|
||||||
|
selectedFileName={selectedFileName}
|
||||||
|
selectedFileSize={selectedFileSize}
|
||||||
|
isLoading={isUploading}
|
||||||
|
uploadProgress={uploadProgress}
|
||||||
|
errorMessage={uploadError ?? errorMessage}
|
||||||
|
onClear={clear}
|
||||||
|
fileRegister={fileRegister}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageUploadField;
|
||||||
@@ -22,6 +22,7 @@ interface StepperProps {
|
|||||||
onStepChange?: (step: number) => void;
|
onStepChange?: (step: number) => void;
|
||||||
onSubmit?: () => void;
|
onSubmit?: () => void;
|
||||||
isSubmitting?: boolean;
|
isSubmitting?: boolean;
|
||||||
|
nextDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Stepper: React.FC<StepperProps> = ({
|
const Stepper: React.FC<StepperProps> = ({
|
||||||
@@ -30,6 +31,7 @@ const Stepper: React.FC<StepperProps> = ({
|
|||||||
onStepChange,
|
onStepChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isSubmitting = false,
|
isSubmitting = false,
|
||||||
|
nextDisabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [internalStep, setInternalStep] = useState(0);
|
const [internalStep, setInternalStep] = useState(0);
|
||||||
const currentStep =
|
const currentStep =
|
||||||
@@ -213,6 +215,7 @@ const Stepper: React.FC<StepperProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const nextStep = () => {
|
const nextStep = () => {
|
||||||
|
if (nextDisabled) return;
|
||||||
if (currentStep === steps.length - 1) {
|
if (currentStep === steps.length - 1) {
|
||||||
if (onSubmit) onSubmit();
|
if (onSubmit) onSubmit();
|
||||||
} else if (currentStep < steps.length - 1) {
|
} else if (currentStep < steps.length - 1) {
|
||||||
@@ -425,20 +428,20 @@ const Stepper: React.FC<StepperProps> = ({
|
|||||||
onClick={nextStep}
|
onClick={nextStep}
|
||||||
onMouseEnter={() => handleButtonHover(nextBtnRef.current, true)}
|
onMouseEnter={() => handleButtonHover(nextBtnRef.current, true)}
|
||||||
onMouseLeave={() => handleButtonHover(nextBtnRef.current, false)}
|
onMouseLeave={() => handleButtonHover(nextBtnRef.current, false)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || nextDisabled}
|
||||||
className={`group relative inline-flex items-center gap-2 overflow-hidden rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all ${
|
className={`group relative inline-flex items-center gap-2 overflow-hidden rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all ${
|
||||||
isSubmitting
|
isSubmitting || nextDisabled
|
||||||
? "cursor-not-allowed bg-gray-4"
|
? "cursor-not-allowed bg-gray-4"
|
||||||
: "bg-gradient-to-r from-primary via-blue to-primary bg-[length:200%_100%] shadow-lg shadow-primary/30 hover:shadow-primary/50"
|
: "bg-gradient-to-r from-primary via-blue to-primary bg-[length:200%_100%] shadow-lg shadow-primary/30 hover:shadow-primary/50"
|
||||||
}`}
|
}`}
|
||||||
style={
|
style={
|
||||||
!isSubmitting
|
!isSubmitting && !nextDisabled
|
||||||
? { animation: "stepperShimmer 3s linear infinite" }
|
? { animation: "stepperShimmer 3s linear infinite" }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Glow halo for submit */}
|
{/* Glow halo for submit */}
|
||||||
{isLastStep && !isSubmitting && (
|
{isLastStep && !isSubmitting && !nextDisabled && (
|
||||||
<span
|
<span
|
||||||
ref={submitGlowRef}
|
ref={submitGlowRef}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { createContext } from "react";
|
import { createContext } from "react";
|
||||||
|
|
||||||
export type SortDirection = "asc" | "desc";
|
export type SortDirection = "asc" | "desc";
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { fileService } from "@/lib/api";
|
||||||
|
import { extractFileId } from "@/lib/api/config";
|
||||||
|
import { userService } from "@/lib/api/services/user-services";
|
||||||
|
import {
|
||||||
|
ALLOWED_IMAGE_FILE_TYPES,
|
||||||
|
MAX_IMAGE_FILE_SIZE,
|
||||||
|
validateImageFile,
|
||||||
|
} from "@/utils/shared";
|
||||||
|
import { ChangeEvent, useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
/** Currently stored URL for this field (empty string when none). */
|
||||||
|
value?: string;
|
||||||
|
/** Persist the uploaded file URL (or empty string when cleared). */
|
||||||
|
onChange: (url: string) => void;
|
||||||
|
category?: string;
|
||||||
|
tags?: string[];
|
||||||
|
description?: string;
|
||||||
|
allowedTypes?: string[];
|
||||||
|
maxSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IFileFieldValues {
|
||||||
|
file?: FileList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable upload lifecycle for a single image field that stores a URL string.
|
||||||
|
* Generalizes the profile-image flow from `useCreateAdminPresenter` so any form
|
||||||
|
* (e.g. marketing card icons) can drop in a `FileImageUploadCard`.
|
||||||
|
*/
|
||||||
|
export const useImageUploadFieldPresenter = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
category = "cms",
|
||||||
|
tags = ["cms", "marketing"],
|
||||||
|
description = "Marketing media",
|
||||||
|
allowedTypes = ALLOWED_IMAGE_FILE_TYPES,
|
||||||
|
maxSize = MAX_IMAGE_FILE_SIZE,
|
||||||
|
}: IProps) => {
|
||||||
|
const [selectedFileName, setSelectedFileName] = useState<string>("");
|
||||||
|
const [selectedFileSize, setSelectedFileSize] = useState<number | null>(null);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<number>(0);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const { register, setValue } = useForm<IFileFieldValues>({
|
||||||
|
defaultValues: { file: undefined },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build a previewable source from the stored value. Backend file URLs are
|
||||||
|
// auth-protected, so fetch them through the proxy into a local object URL.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value) {
|
||||||
|
setPreviewUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!extractFileId(value)) {
|
||||||
|
setPreviewUrl(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
userService
|
||||||
|
.fetchFileObjectUrl(value)
|
||||||
|
.then((objectUrl) => {
|
||||||
|
if (cancelled) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreviewUrl(objectUrl);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Leave the preview empty rather than showing a broken image.
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// Revoke local blob previews when they are replaced or unmounted.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (previewUrl?.startsWith("blob:")) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [previewUrl]);
|
||||||
|
|
||||||
|
const resetSelection = () => {
|
||||||
|
setSelectedFileName("");
|
||||||
|
setSelectedFileSize(null);
|
||||||
|
setUploadProgress(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
const input = event.target;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
resetSelection();
|
||||||
|
setErrorMessage(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationError = validateImageFile({ file, allowedTypes, maxSize });
|
||||||
|
if (validationError) {
|
||||||
|
setErrorMessage(validationError);
|
||||||
|
input.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorMessage(undefined);
|
||||||
|
setSelectedFileName(file.name);
|
||||||
|
setSelectedFileSize(file.size);
|
||||||
|
setIsUploading(true);
|
||||||
|
setUploadProgress(0);
|
||||||
|
// Preview the local file directly while the upload runs.
|
||||||
|
setPreviewUrl(URL.createObjectURL(file));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = await fileService.uploadFile({
|
||||||
|
file,
|
||||||
|
category,
|
||||||
|
tags,
|
||||||
|
description,
|
||||||
|
onProgress: setUploadProgress,
|
||||||
|
});
|
||||||
|
setUploadProgress(100);
|
||||||
|
onChange(url);
|
||||||
|
} catch {
|
||||||
|
resetSelection();
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setValue("file", undefined);
|
||||||
|
onChange("");
|
||||||
|
setErrorMessage("Failed to upload image");
|
||||||
|
input.value = "";
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
setValue("file", undefined);
|
||||||
|
resetSelection();
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setErrorMessage(undefined);
|
||||||
|
onChange("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileRegister = register("file", { onChange: onFileChange });
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileRegister,
|
||||||
|
isUploading,
|
||||||
|
uploadProgress,
|
||||||
|
selectedFileName,
|
||||||
|
selectedFileSize,
|
||||||
|
previewUrl,
|
||||||
|
errorMessage,
|
||||||
|
clear,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -70,10 +70,10 @@ export const _TooltipDefaultParams = ({
|
|||||||
delayHide: 120,
|
delayHide: 120,
|
||||||
variant,
|
variant,
|
||||||
className: "fancy-tooltip",
|
className: "fancy-tooltip",
|
||||||
|
border: `1px solid ${palette.border}`,
|
||||||
style: {
|
style: {
|
||||||
background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)`,
|
background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)`,
|
||||||
boxShadow: palette.shadow,
|
boxShadow: palette.shadow,
|
||||||
border: `1px solid ${palette.border}`,
|
|
||||||
...styles,
|
...styles,
|
||||||
} satisfies CSSProperties,
|
} satisfies CSSProperties,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface UserContextType {
|
|||||||
setAuthState: (data: ILoginResponse) => void;
|
setAuthState: (data: ILoginResponse) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
setUser: Dispatch<SetStateAction<IUser | null>>;
|
setUser: Dispatch<SetStateAction<IUser | null>>;
|
||||||
|
syncCurrentUser: (updates: Partial<IUser>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserContext = createContext<UserContextType | undefined>(undefined);
|
const UserContext = createContext<UserContextType | undefined>(undefined);
|
||||||
@@ -99,6 +100,16 @@ export const UserProvider: React.FC<UserProviderProps> = ({ children }) => {
|
|||||||
Cookies.set(COOKIE_KEYS.user, JSON.stringify(data.user));
|
Cookies.set(COOKIE_KEYS.user, JSON.stringify(data.user));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const syncCurrentUser = useCallback((updates: Partial<IUser>) => {
|
||||||
|
setUser((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
|
||||||
|
const next = { ...prev, ...updates };
|
||||||
|
Cookies.set(COOKIE_KEYS.user, JSON.stringify(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
user,
|
user,
|
||||||
@@ -108,8 +119,9 @@ export const UserProvider: React.FC<UserProviderProps> = ({ children }) => {
|
|||||||
setAuthState,
|
setAuthState,
|
||||||
logout,
|
logout,
|
||||||
setUser,
|
setUser,
|
||||||
|
syncCurrentUser,
|
||||||
}),
|
}),
|
||||||
[user, accessToken, isLoading, setAuthState, logout, setUser],
|
[user, accessToken, isLoading, setAuthState, logout, setUser, syncCurrentUser],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
|
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export * from "./useNotificationQueries";
|
|||||||
export * from "./useProfileQueries";
|
export * from "./useProfileQueries";
|
||||||
export * from "./useTenders";
|
export * from "./useTenders";
|
||||||
export * from "./useUsersQueries";
|
export * from "./useUsersQueries";
|
||||||
export * from "./useAdminListQuery";
|
export * from "./useAdminQueries";
|
||||||
export * from "./useSelectSearchQuery";
|
export * from "./useSelectSearchQuery";
|
||||||
export * from "./useDashboardQueries";
|
export * from "./useDashboardQueries";
|
||||||
|
export * from "./useFilesQueries";
|
||||||
|
|||||||
@@ -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,
|
API_ENDPOINTS,
|
||||||
companiesService,
|
companiesService,
|
||||||
companyCategoriesService,
|
companyCategoriesService,
|
||||||
|
type ICompanyLink,
|
||||||
type TCompanyCategoryApiResponse,
|
type TCompanyCategoryApiResponse,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
@@ -31,7 +32,18 @@ export const useCreateCompany = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useUpdateCompany = (id: string) => {
|
interface UseUpdateCompanyOptions {
|
||||||
|
redirectOnSuccess?: boolean;
|
||||||
|
showSuccessToast?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpdateCompany = (
|
||||||
|
id: string,
|
||||||
|
{
|
||||||
|
redirectOnSuccess = true,
|
||||||
|
showSuccessToast = true,
|
||||||
|
}: UseUpdateCompanyOptions = {},
|
||||||
|
) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const mutationKey = useMemo(
|
const mutationKey = useMemo(
|
||||||
@@ -42,14 +54,21 @@ export const useUpdateCompany = (id: string) => {
|
|||||||
mutationKey,
|
mutationKey,
|
||||||
mutationFn: companiesService.updateCompany,
|
mutationFn: companiesService.updateCompany,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
if (showSuccessToast) {
|
||||||
toast.success("Company updated successfully");
|
toast.success("Company updated successfully");
|
||||||
|
}
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL],
|
queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL],
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL, "details"],
|
queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL, "details"],
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL, id, "details"],
|
||||||
|
});
|
||||||
|
if (redirectOnSuccess) {
|
||||||
router.push("/companies");
|
router.push("/companies");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -64,6 +83,44 @@ export const useCompanyDetails = (id: string) => {
|
|||||||
queryFn: () => companiesService.companyDetails(id),
|
queryFn: () => companiesService.companyDetails(id),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
export const useUploadCompanyDocuments = (id: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const mutationKey = useMemo(
|
||||||
|
() => [API_ENDPOINTS.COMPANIES.DOCUMENTS(id), "upload-documents"],
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
return useMutation({
|
||||||
|
mutationKey,
|
||||||
|
mutationFn: (files: File[]) =>
|
||||||
|
companiesService.uploadDocuments({ id, files }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL, id, "details"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAddCompanyLinks = (id: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const mutationKey = useMemo(
|
||||||
|
() => [API_ENDPOINTS.COMPANIES.LINKS(id), "add-links"],
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationKey,
|
||||||
|
mutationFn: (links: ICompanyLink[]) =>
|
||||||
|
companiesService.addLinks({ id, links }),
|
||||||
|
onSuccess: (response) => {
|
||||||
|
toast.success(response.message ?? "Company links added successfully");
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL, id, "details"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useDeleteCompanyQuery = (successCallback?: () => void) => {
|
export const useDeleteCompanyQuery = (successCallback?: () => void) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const mutationKey = useMemo(
|
const mutationKey = useMemo(
|
||||||
@@ -188,7 +245,8 @@ export const useDeleteCompanyCategoryQuery = (successCallback?: () => void) => {
|
|||||||
};
|
};
|
||||||
export const useToggleCompanyCategoryPublished = () => {
|
export const useToggleCompanyCategoryPublished = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const updateOptimisticCategories = useCallback((categoryId: string) => {
|
const updateOptimisticCategories = useCallback(
|
||||||
|
(categoryId: string) => {
|
||||||
queryClient.setQueriesData<TCompanyCategoryApiResponse>(
|
queryClient.setQueriesData<TCompanyCategoryApiResponse>(
|
||||||
{
|
{
|
||||||
queryKey: [API_ENDPOINTS.COMPANIES.CATEGORIES.READ_ALL],
|
queryKey: [API_ENDPOINTS.COMPANIES.CATEGORIES.READ_ALL],
|
||||||
@@ -211,7 +269,9 @@ export const useToggleCompanyCategoryPublished = () => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [queryClient]);
|
},
|
||||||
|
[queryClient],
|
||||||
|
);
|
||||||
const mutationKey = useMemo(
|
const mutationKey = useMemo(
|
||||||
() => [
|
() => [
|
||||||
API_ENDPOINTS.COMPANIES.CATEGORIES.TOGGLE_PUBLISHED("published"),
|
API_ENDPOINTS.COMPANIES.CATEGORIES.TOGGLE_PUBLISHED("published"),
|
||||||
@@ -227,8 +287,7 @@ export const useToggleCompanyCategoryPublished = () => {
|
|||||||
data?: { message?: string };
|
data?: { message?: string };
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
const toastMessage =
|
const toastMessage = responseBody.data?.message ?? responseBody.message;
|
||||||
responseBody.data?.message ?? responseBody.message;
|
|
||||||
if (toastMessage) {
|
if (toastMessage) {
|
||||||
toast.success(toastMessage);
|
toast.success(toastMessage);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ export const useUpdateCustomer = (id: string) => {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: [API_ENDPOINTS.CUSTOMERS.READ_ALL],
|
queryKey: [API_ENDPOINTS.CUSTOMERS.READ_ALL],
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["GET ALL CUSTOMERS", id],
|
||||||
|
});
|
||||||
router.push("/customers");
|
router.push("/customers");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useMemo } from "react";
|
|||||||
|
|
||||||
export const useDashboardSummaryQuery = (params?: {
|
export const useDashboardSummaryQuery = (params?: {
|
||||||
closing_window?: number;
|
closing_window?: number;
|
||||||
|
days?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const queryKey = useMemo(
|
const queryKey = useMemo(
|
||||||
() => [API_ENDPOINTS.DASHBOARD.SUMMARY, params],
|
() => [API_ENDPOINTS.DASHBOARD.SUMMARY, params],
|
||||||
@@ -29,6 +30,18 @@ export const useDashboardTrendQuery = (params?: {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useDashboardStatisticsQuery = (params?: { days?: number }) => {
|
||||||
|
const queryKey = useMemo(
|
||||||
|
() => [API_ENDPOINTS.DASHBOARD.STATISTICS, params],
|
||||||
|
[params],
|
||||||
|
);
|
||||||
|
return useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => dashboardService.statistics(params),
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useDashboardCountriesQuery = (params?: { limit?: number }) => {
|
export const useDashboardCountriesQuery = (params?: { limit?: number }) => {
|
||||||
const queryKey = useMemo(
|
const queryKey = useMemo(
|
||||||
() => [API_ENDPOINTS.DASHBOARD.COUNTRIES, params],
|
() => [API_ENDPOINTS.DASHBOARD.COUNTRIES, params],
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { API_ENDPOINTS, fileService } from "@/lib/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
/** Fetches (and caches) metadata for a single stored file. */
|
||||||
|
export const useFileInfo = (fileId: string, options?: { enabled?: boolean }) => {
|
||||||
|
const queryKey = useMemo(() => [API_ENDPOINTS.FILES.INFO(fileId)], [fileId]);
|
||||||
|
return useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => fileService.getFileInfo(fileId),
|
||||||
|
enabled: (options?.enabled ?? true) && Boolean(fileId),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user