chore: update app version and enhance UI components
- Bumped NEXT_PUBLIC_APP_VERSION to 2.0.3 for the latest release. - Improved Cypress test for customer feedback page to handle delayed route transitions. - Refactored CompanyDetailsPage to enhance layout and added new components for better structure. - Updated TenderDetails to conditionally render location and currency information based on validity checks. - Added data-cy attributes to various form elements and tables for improved testing capabilities.
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
NEXT_PUBLIC_APP_VERSION=2.0.2
|
NEXT_PUBLIC_APP_VERSION=2.0.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,149 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
import { makeCompanyCategory } from "../../support/company-category-fixtures";
|
||||||
|
import {
|
||||||
|
categoryMutationSuccessBody,
|
||||||
|
companyCategoriesListUrl,
|
||||||
|
companyCategoryCreateUrl,
|
||||||
|
companyCategoryEditUrl,
|
||||||
|
} from "../../support/company-category-helpers";
|
||||||
|
import { stubCompanyCategoriesReads } from "../../support/company-category-intercepts";
|
||||||
|
|
||||||
|
describe("company categories create and edit forms", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.setAuthCookies();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("create: shows required error when name is empty", () => {
|
||||||
|
stubCompanyCategoriesReads(() => []);
|
||||||
|
cy.visit(companyCategoryCreateUrl());
|
||||||
|
cy.getByCy("company-category-form").should("be.visible");
|
||||||
|
cy.contains("h2", "Create Company Category").should("be.visible");
|
||||||
|
cy.getByCy("admin-form-submit-button").click();
|
||||||
|
cy.contains("This field is required").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("create: shows min length for one-character name", () => {
|
||||||
|
stubCompanyCategoriesReads(() => []);
|
||||||
|
cy.visit(companyCategoryCreateUrl());
|
||||||
|
cy.getByCy("company-category-form-name-input").type("A");
|
||||||
|
cy.getByCy("admin-form-submit-button").click();
|
||||||
|
cy.contains("This field must be at least 2 characters long").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("create: shows min length when description is too short", () => {
|
||||||
|
stubCompanyCategoriesReads(() => []);
|
||||||
|
cy.visit(companyCategoryCreateUrl());
|
||||||
|
cy.getByCy("company-category-form-name-input").type("Valid Name");
|
||||||
|
cy.getByCy("company-category-form-description-input").type("short");
|
||||||
|
cy.getByCy("admin-form-submit-button").click();
|
||||||
|
cy.contains("This field must be at least 10 characters long").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("create: submits and redirects to list with success toast", () => {
|
||||||
|
let categories: ReturnType<typeof makeCompanyCategory>[] = [];
|
||||||
|
stubCompanyCategoriesReads(() => categories);
|
||||||
|
|
||||||
|
cy.intercept("POST", "**/api/proxy/company-categories", (req) => {
|
||||||
|
categories = [
|
||||||
|
makeCompanyCategory(
|
||||||
|
{
|
||||||
|
id: "507f1f77bcf86cd799439099",
|
||||||
|
name: "New Category",
|
||||||
|
description: "A description with enough characters.",
|
||||||
|
},
|
||||||
|
99,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
req.reply({
|
||||||
|
statusCode: 201,
|
||||||
|
body: categoryMutationSuccessBody("Category created"),
|
||||||
|
});
|
||||||
|
}).as("createCategory");
|
||||||
|
|
||||||
|
cy.visit(companyCategoryCreateUrl());
|
||||||
|
cy.getByCy("company-category-form-name-input").type("New Category");
|
||||||
|
cy.getByCy("company-category-form-description-input").type(
|
||||||
|
"A description with enough characters.",
|
||||||
|
);
|
||||||
|
cy.getByCy("admin-form-submit-button").click();
|
||||||
|
cy.wait("@createCategory");
|
||||||
|
// Next.js navigation can be slightly delayed in CI/headless runs.
|
||||||
|
cy.location("pathname", { timeout: 15000 }).should("eq", "/company-categories");
|
||||||
|
cy.contains(/success|created|Category created/i).should("be.visible");
|
||||||
|
cy.contains("New Category").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("create: disables submit while request is in flight", () => {
|
||||||
|
stubCompanyCategoriesReads(() => []);
|
||||||
|
cy.intercept("POST", "**/api/proxy/company-categories", (req) => {
|
||||||
|
req.reply({
|
||||||
|
delay: 1400,
|
||||||
|
statusCode: 201,
|
||||||
|
body: categoryMutationSuccessBody("Category created"),
|
||||||
|
});
|
||||||
|
}).as("slowCreate");
|
||||||
|
|
||||||
|
cy.visit(companyCategoryCreateUrl());
|
||||||
|
cy.getByCy("company-category-form-name-input").type("Slow Category");
|
||||||
|
cy.getByCy("company-category-form-description-input").type(
|
||||||
|
"Description long enough for validation rules here.",
|
||||||
|
);
|
||||||
|
cy.getByCy("admin-form-submit-button").click();
|
||||||
|
cy.getByCy("admin-form-submit-button").should("be.disabled");
|
||||||
|
cy.getByCy("admin-form-submit-button").find("div.animate-spin").should("exist");
|
||||||
|
cy.wait("@slowCreate");
|
||||||
|
cy.get("@slowCreate.all").should("have.length", 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edit: prefills fields and updates then returns to list", () => {
|
||||||
|
const cat = makeCompanyCategory({ name: "Before Name", id: "507f1f77bcf86cd799439055" }, 5);
|
||||||
|
let categories = [cat];
|
||||||
|
|
||||||
|
stubCompanyCategoriesReads(() => categories);
|
||||||
|
cy.intercept("PUT", `**/api/proxy/company-categories/${cat.id}`, (req) => {
|
||||||
|
categories = [
|
||||||
|
makeCompanyCategory(
|
||||||
|
{
|
||||||
|
...cat,
|
||||||
|
name: "After Name",
|
||||||
|
},
|
||||||
|
5,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
req.reply({
|
||||||
|
statusCode: 200,
|
||||||
|
body: categoryMutationSuccessBody("Category updated"),
|
||||||
|
});
|
||||||
|
}).as("updateCategory");
|
||||||
|
|
||||||
|
cy.visit(companyCategoryEditUrl(cat.id));
|
||||||
|
cy.contains("h2", "Edit Company Category").should("be.visible");
|
||||||
|
cy.getByCy("company-category-form-name-input").should("have.value", "Before Name");
|
||||||
|
cy.getByCy("company-category-form-name-input").clear().type("After Name");
|
||||||
|
cy.getByCy("admin-form-submit-button").click();
|
||||||
|
cy.wait("@updateCategory");
|
||||||
|
cy.location("pathname").should("eq", "/company-categories");
|
||||||
|
cy.contains(/success|updated|Category updated/i).should("be.visible");
|
||||||
|
cy.contains("After Name").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edit: cancel uses back navigation", () => {
|
||||||
|
const cat = makeCompanyCategory({}, 1);
|
||||||
|
stubCompanyCategoriesReads(() => [cat]);
|
||||||
|
|
||||||
|
cy.visit(companyCategoriesListUrl());
|
||||||
|
cy.getByCy(`company-categories-row-edit-${cat.id}`).click();
|
||||||
|
cy.location("pathname").should("eq", `/company-categories/edit/${cat.id}`);
|
||||||
|
cy.getByCy("admin-form-cancel-button").click();
|
||||||
|
cy.location("pathname").should("eq", "/company-categories");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("create: cancel navigates back when opened from list", () => {
|
||||||
|
stubCompanyCategoriesReads(() => [makeCompanyCategory({}, 1)]);
|
||||||
|
cy.visit(companyCategoriesListUrl());
|
||||||
|
cy.getByCy("company-categories-create-button").click();
|
||||||
|
cy.location("pathname").should("eq", "/company-categories/create");
|
||||||
|
cy.getByCy("admin-form-cancel-button").click();
|
||||||
|
cy.location("pathname").should("eq", "/company-categories");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
import { makeCompanyCategory } from "../../support/company-category-fixtures";
|
||||||
|
import { companyCategoriesListUrl } from "../../support/company-category-helpers";
|
||||||
|
import { stubCompanyCategoriesReads } from "../../support/company-category-intercepts";
|
||||||
|
|
||||||
|
describe("company categories list", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.setAuthCookies();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles published optimistically, then keeps state after PATCH succeeds", () => {
|
||||||
|
const cat = makeCompanyCategory({ published: false }, 1);
|
||||||
|
let categories: ReturnType<typeof makeCompanyCategory>[] = [cat];
|
||||||
|
|
||||||
|
stubCompanyCategoriesReads(() => categories);
|
||||||
|
cy.intercept("PATCH", "**/api/proxy/company-categories/**/publish", (req) => {
|
||||||
|
const m = req.url.match(/company-categories\/([^/]+)\/publish/);
|
||||||
|
const id = m?.[1];
|
||||||
|
if (id) {
|
||||||
|
categories = categories.map((c) =>
|
||||||
|
c.id === id ? { ...c, published: !c.published } : c,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
req.reply({
|
||||||
|
statusCode: 200,
|
||||||
|
body: { success: true, message: "Publish status updated", data: {} },
|
||||||
|
});
|
||||||
|
}).as("togglePublished");
|
||||||
|
|
||||||
|
cy.visit(companyCategoriesListUrl());
|
||||||
|
const toggle = () => cy.getByCy("company-categories-published-toggle-" + cat.id);
|
||||||
|
toggle().should("not.be.checked");
|
||||||
|
toggle().click({ force: true });
|
||||||
|
// Optimistic update (useOptimistic) before the network call finishes.
|
||||||
|
toggle().should("be.checked");
|
||||||
|
cy.wait("@togglePublished");
|
||||||
|
toggle().should("be.checked");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverts published toggle when PATCH fails", () => {
|
||||||
|
const cat = makeCompanyCategory({ published: false }, 1);
|
||||||
|
stubCompanyCategoriesReads(() => [cat]);
|
||||||
|
cy.intercept("PATCH", "**/api/proxy/company-categories/**/publish", {
|
||||||
|
statusCode: 500,
|
||||||
|
body: { success: false, message: "Server error" },
|
||||||
|
delay: 200,
|
||||||
|
}).as("togglePublishedFail");
|
||||||
|
|
||||||
|
cy.visit(companyCategoriesListUrl());
|
||||||
|
const toggle = () => cy.getByCy("company-categories-published-toggle-" + cat.id);
|
||||||
|
toggle().should("not.be.checked");
|
||||||
|
toggle().click({ force: true });
|
||||||
|
toggle().should("be.checked");
|
||||||
|
cy.wait("@togglePublishedFail");
|
||||||
|
toggle().should("not.be.checked");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens filter modal, searches by q, and updates the list", () => {
|
||||||
|
const alpha = makeCompanyCategory({ name: "Alpha Row", id: "507f1f77bcf86cd799439011" }, 1);
|
||||||
|
const beta = makeCompanyCategory({ name: "Beta Row", id: "507f1f77bcf86cd799439012" }, 2);
|
||||||
|
let categories = [alpha, beta];
|
||||||
|
|
||||||
|
stubCompanyCategoriesReads(() => categories);
|
||||||
|
|
||||||
|
cy.visit(companyCategoriesListUrl());
|
||||||
|
cy.contains("Alpha Row").should("be.visible");
|
||||||
|
cy.getByCy("company-categories-filter-button").click();
|
||||||
|
cy.getByCy("company-categories-filter-form").should("be.visible");
|
||||||
|
cy.getByCy("company-categories-filter-search-input").clear().type("Beta");
|
||||||
|
cy.then(() => { categories = [beta]; });
|
||||||
|
cy.getByCy("company-categories-filter-submit-button").click();
|
||||||
|
cy.location("search").should("include", "q=Beta");
|
||||||
|
cy.contains("Beta Row").should("be.visible");
|
||||||
|
cy.contains("Alpha Row").should("not.exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by published select and clears published filter", () => {
|
||||||
|
const pub = makeCompanyCategory({ name: "Pub Cat", published: true }, 1);
|
||||||
|
const draft = makeCompanyCategory(
|
||||||
|
{ name: "Draft Cat", published: false, id: "507f1f77bcf86cd799439013" },
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
let categories = [pub, draft];
|
||||||
|
|
||||||
|
stubCompanyCategoriesReads(() => categories);
|
||||||
|
|
||||||
|
cy.visit(companyCategoriesListUrl());
|
||||||
|
cy.getByCy("company-categories-filter-button").click();
|
||||||
|
cy.getByCy("company-categories-filter-published-select").select("false");
|
||||||
|
cy.then(() => { categories = [draft]; });
|
||||||
|
cy.getByCy("company-categories-filter-submit-button").click();
|
||||||
|
cy.location("search").should("include", "published=false");
|
||||||
|
cy.contains("Draft Cat").should("be.visible");
|
||||||
|
cy.contains("Pub Cat").should("not.exist");
|
||||||
|
|
||||||
|
cy.getByCy("company-categories-filter-button").click();
|
||||||
|
cy.getByCy("company-categories-filter-published-select-clear").click();
|
||||||
|
cy.then(() => { categories = [pub, draft]; });
|
||||||
|
cy.getByCy("company-categories-filter-submit-button").click();
|
||||||
|
cy.location("search").should("not.include", "published=false");
|
||||||
|
cy.contains("Pub Cat").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes filter modal without applying", () => {
|
||||||
|
const cat = makeCompanyCategory({}, 1);
|
||||||
|
stubCompanyCategoriesReads(() => [cat]);
|
||||||
|
|
||||||
|
cy.visit(companyCategoriesListUrl());
|
||||||
|
cy.getByCy("company-categories-filter-button").click();
|
||||||
|
cy.getByCy("company-categories-filter-form").should("be.visible");
|
||||||
|
cy.getByCy("company-categories-filter-close-button").click();
|
||||||
|
cy.getByCy("company-categories-filter-form").should("not.exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to create from header button", () => {
|
||||||
|
stubCompanyCategoriesReads(() => [makeCompanyCategory({}, 1)]);
|
||||||
|
cy.visit(companyCategoriesListUrl());
|
||||||
|
cy.getByCy("company-categories-create-button").click();
|
||||||
|
cy.location("pathname").should("eq", "/company-categories/create");
|
||||||
|
cy.getByCy("company-category-form").should("be.visible");
|
||||||
|
cy.contains("h2", "Create Company Category").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens edit route for a row", () => {
|
||||||
|
const cat = makeCompanyCategory({}, 1);
|
||||||
|
stubCompanyCategoriesReads(() => [cat]);
|
||||||
|
|
||||||
|
cy.visit(companyCategoriesListUrl());
|
||||||
|
cy.getByCy(`company-categories-row-edit-${cat.id}`).click();
|
||||||
|
cy.location("pathname").should("eq", `/company-categories/edit/${cat.id}`);
|
||||||
|
cy.contains("h2", "Edit Company Category").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes after confirm and shows empty state", () => {
|
||||||
|
const cat = makeCompanyCategory({}, 1);
|
||||||
|
let categories = [cat];
|
||||||
|
|
||||||
|
stubCompanyCategoriesReads(() => categories);
|
||||||
|
cy.intercept("DELETE", `**/api/proxy/company-categories/${cat.id}`, {
|
||||||
|
statusCode: 200,
|
||||||
|
body: { success: true, message: "Deleted", data: {} },
|
||||||
|
}).as("deleteCategory");
|
||||||
|
|
||||||
|
cy.visit(companyCategoriesListUrl());
|
||||||
|
cy.getByCy(`company-categories-row-delete-${cat.id}`).click();
|
||||||
|
cy.getByCy("confirmation-modal").should("be.visible");
|
||||||
|
cy.getByCy("confirmation-modal-cancel").click();
|
||||||
|
cy.getByCy("confirmation-modal").should("not.exist");
|
||||||
|
|
||||||
|
cy.getByCy(`company-categories-row-delete-${cat.id}`).click();
|
||||||
|
cy.then(() => { categories = []; });
|
||||||
|
cy.getByCy("confirmation-modal-confirm").click();
|
||||||
|
cy.wait("@deleteCategory");
|
||||||
|
cy.getByCy("empty-list-message").should("contain.text", "No categories were found");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -366,7 +366,11 @@ describe("customers management - list, actions, filters, feedback", () => {
|
|||||||
cy.visit("/customers");
|
cy.visit("/customers");
|
||||||
cy.wait("@getCustomers");
|
cy.wait("@getCustomers");
|
||||||
cy.get('button[data-tooltip-id="feedback"]').first().click();
|
cy.get('button[data-tooltip-id="feedback"]').first().click();
|
||||||
cy.location("pathname").should("eq", `/customers/feedback/${customer.id}`);
|
// Route transition can be delayed in headless runs.
|
||||||
|
cy.location("pathname", { timeout: 15000 }).should(
|
||||||
|
"eq",
|
||||||
|
`/customers/feedback/${customer.id}`,
|
||||||
|
);
|
||||||
cy.wait("@getCustomerFeedback");
|
cy.wait("@getCustomerFeedback");
|
||||||
cy.contains("feedback type").should("be.visible");
|
cy.contains("feedback type").should("be.visible");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export type CompanyCategoryRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
published: boolean;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
published_at: number;
|
||||||
|
thumbnail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const makeCompanyCategory = (
|
||||||
|
overrides: Partial<CompanyCategoryRow> = {},
|
||||||
|
index = 1,
|
||||||
|
): CompanyCategoryRow => ({
|
||||||
|
id: `507f1f77bcf86cd7994390${(10 + index).toString().padStart(2, "0")}`,
|
||||||
|
name: `E2E Category ${index}`,
|
||||||
|
description: "This is a sufficiently long description for category rows and forms.",
|
||||||
|
published: false,
|
||||||
|
created_at: 1_700_000_000 + index,
|
||||||
|
updated_at: 1_700_000_000 + index,
|
||||||
|
published_at: 0,
|
||||||
|
thumbnail: "",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const categoriesListResponse = (categories: CompanyCategoryRow[]) => ({
|
||||||
|
success: true,
|
||||||
|
message: "ok",
|
||||||
|
data: { categories },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const categoryDetailsResponse = (data: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
}) => ({
|
||||||
|
success: true,
|
||||||
|
message: "ok",
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
thumbnail: data.thumbnail ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/** Matches `useCreateCompanyCategory` / `useUpdateCompanyCategories` onSuccess (axios `response.data` shape). */
|
||||||
|
export const categoryMutationSuccessBody = (message: string) => ({
|
||||||
|
success: true,
|
||||||
|
data: { message },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const companyCategoriesListUrl = (path = "/company-categories") =>
|
||||||
|
`${path}?_cy=${Date.now()}`;
|
||||||
|
|
||||||
|
export const companyCategoryCreateUrl = () =>
|
||||||
|
`/company-categories/create?_cy=${Date.now()}`;
|
||||||
|
|
||||||
|
export const companyCategoryEditUrl = (id: string) =>
|
||||||
|
`/company-categories/edit/${id}?_cy=${Date.now()}`;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
categoriesListResponse,
|
||||||
|
categoryDetailsResponse,
|
||||||
|
type CompanyCategoryRow,
|
||||||
|
} from "./company-category-fixtures";
|
||||||
|
|
||||||
|
/** Subset of Cypress `cy.intercept` request used by this stub (avoids non-exported / deep Cypress types). */
|
||||||
|
type InterceptRequest = {
|
||||||
|
url: string;
|
||||||
|
reply(staticResponse: { statusCode: number; body: Record<string, unknown> }): void;
|
||||||
|
continue(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_ID_IN_URL = /\/company-categories\/([a-f0-9]{24})(?:\?|\/|$)/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stubs GET list and GET single category. Uses `req.url` string matching so all
|
||||||
|
* request shapes (query strings, rewritten hosts) are handled consistently.
|
||||||
|
*/
|
||||||
|
export function stubCompanyCategoriesReads(
|
||||||
|
getCategories: () => CompanyCategoryRow[],
|
||||||
|
): void {
|
||||||
|
const reply = (req: InterceptRequest) => {
|
||||||
|
const raw = req.url;
|
||||||
|
const detailMatch = raw.match(CATEGORY_ID_IN_URL);
|
||||||
|
if (detailMatch) {
|
||||||
|
const id = detailMatch[1];
|
||||||
|
const cat = getCategories().find((c) => c.id === id);
|
||||||
|
req.reply({
|
||||||
|
statusCode: 200,
|
||||||
|
body: categoryDetailsResponse({
|
||||||
|
name: cat?.name ?? "Category",
|
||||||
|
description: cat?.description ?? "Default description long enough.",
|
||||||
|
thumbnail: cat?.thumbnail ?? "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.includes("/company-categories")) {
|
||||||
|
req.reply({
|
||||||
|
statusCode: 200,
|
||||||
|
body: categoriesListResponse(getCategories()),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.continue();
|
||||||
|
};
|
||||||
|
|
||||||
|
cy.intercept("GET", /\/api\/proxy\/company-categories/, reply);
|
||||||
|
cy.intercept("GET", /\/admin\/v1\/company-categories/, reply);
|
||||||
|
}
|
||||||
@@ -2,20 +2,56 @@
|
|||||||
import Loading from "@/components/loading";
|
import Loading from "@/components/loading";
|
||||||
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
|
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
|
||||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||||
import IsVisible from "@/components/ui/IsVisible";
|
|
||||||
import Status from "@/components/ui/Status";
|
import Status from "@/components/ui/Status";
|
||||||
import { useCompanyDetails } from "@/hooks/queries";
|
import { useCompanyDetails } from "@/hooks/queries";
|
||||||
|
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";
|
||||||
import getSymbolFromCurrency from "currency-symbol-map";
|
import getSymbolFromCurrency from "currency-symbol-map";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { use } from "react";
|
import { use } from "react";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SectionHeading({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<span className="h-px flex-1 bg-gradient-to-r from-stroke/0 via-stroke/80 to-stroke/0 dark:from-dark-3/0 dark:via-dark-3 dark:to-dark-3/0" />
|
||||||
|
<h2 className="shrink-0 text-[11px] font-semibold uppercase tracking-[0.2em] text-dark-5 dark:text-dark-6">
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
<span className="h-px flex-1 bg-gradient-to-l from-stroke/0 via-stroke/80 to-stroke/0 dark:from-dark-3/0 dark:via-dark-3 dark:to-dark-3/0" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExternalLinkIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={cn("h-4 w-4 shrink-0", className)}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHOWCASE_CENTERED_BODY = {
|
||||||
|
rootClassName: "flex h-full min-h-0 flex-col",
|
||||||
|
className:
|
||||||
|
"flex min-h-0 flex-1 flex-col items-center justify-center text-center",
|
||||||
|
} as const;
|
||||||
|
|
||||||
const CompanyDetailsPage = ({ params }: IProps) => {
|
const CompanyDetailsPage = ({ params }: IProps) => {
|
||||||
const { id } = use(params);
|
const { id } = use(params);
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
const breadcrumbItems: BreadcrumbItem[] = [
|
||||||
@@ -33,8 +69,9 @@ const CompanyDetailsPage = ({ params }: IProps) => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
const { data, isLoading } = useCompanyDetails(id);
|
const { data, isLoading } = useCompanyDetails(id);
|
||||||
const router = useRouter();
|
|
||||||
if (isLoading) return <Loading />;
|
if (isLoading) return <Loading />;
|
||||||
|
const company = data.data;
|
||||||
|
const website = company.website?.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -42,71 +79,142 @@ const CompanyDetailsPage = ({ params }: IProps) => {
|
|||||||
|
|
||||||
<ShowcaseSection
|
<ShowcaseSection
|
||||||
title={
|
title={
|
||||||
<div className="flex justify-between">
|
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<strong>{data.data.name}</strong>
|
<div className="min-w-0 space-y-2">
|
||||||
<Status status={data?.data?.status}>{data.data.status}</Status>
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">
|
||||||
|
Company
|
||||||
|
</p>
|
||||||
|
<h1 className="text-balance text-xl font-bold leading-tight text-dark dark:text-white sm:text-2xl lg:text-[1.65rem]">
|
||||||
|
{company.name}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-end">
|
||||||
|
<Status status={company.status}>{company.status}</Status>
|
||||||
|
{website && (
|
||||||
|
<Link
|
||||||
|
href={website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="shadow-theme-xs inline-flex items-center justify-center gap-2 rounded-xl bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:bg-primary/90 hover:shadow-md"
|
||||||
|
>
|
||||||
|
<span>Visit website</span>
|
||||||
|
<ExternalLinkIcon className="opacity-90" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
className="!p-0"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="space-y-10 px-4 pb-8 pt-2 sm:px-6 sm:pb-10 xl:px-10">
|
||||||
<ShowcaseSection title={"Currency"}>
|
<div>
|
||||||
<strong>
|
<SectionHeading>Profile</SectionHeading>
|
||||||
{`${data?.data?.currency} ${getSymbolFromCurrency(data?.data.currency ?? "USD")}`}
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
</strong>
|
<ShowcaseSection title="Industry" {...SHOWCASE_CENTERED_BODY}>
|
||||||
|
<span className="font-medium text-dark dark:text-white">
|
||||||
|
{company.industry || "—"}
|
||||||
|
</span>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
<ShowcaseSection title={"Language"}>
|
<ShowcaseSection title="Type" {...SHOWCASE_CENTERED_BODY}>
|
||||||
<strong className="uppercase">{data?.data?.language}</strong>
|
<span className="font-medium text-dark dark:text-white">
|
||||||
|
{company.type || "—"}
|
||||||
|
</span>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
<ShowcaseSection title={"Created At"}>
|
<ShowcaseSection title="Language" {...SHOWCASE_CENTERED_BODY}>
|
||||||
<strong>{unixToDate({ unix: data?.data?.created_at })}</strong>
|
<span className="font-medium uppercase text-dark dark:text-white">
|
||||||
|
{company.language || "—"}
|
||||||
|
</span>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
<ShowcaseSection title={"Annual revenue"}>
|
<ShowcaseSection title="Founded year" {...SHOWCASE_CENTERED_BODY}>
|
||||||
<strong>{data?.data?.annual_revenue}</strong>
|
<span className="font-medium text-dark dark:text-white">
|
||||||
|
{company.founded_year || "—"}
|
||||||
|
</span>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
<ShowcaseSection title={"Email"}>
|
</div>
|
||||||
<strong>{data?.data?.email}</strong>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionHeading>Business</SectionHeading>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
<ShowcaseSection title="Currency" {...SHOWCASE_CENTERED_BODY}>
|
||||||
|
<span className="font-medium text-dark dark:text-white">
|
||||||
|
{`${company.currency || "—"} ${getSymbolFromCurrency(company.currency ?? "USD")}`}
|
||||||
|
</span>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
<ShowcaseSection title={"Employee count"}>
|
<ShowcaseSection title="Annual revenue" {...SHOWCASE_CENTERED_BODY}>
|
||||||
<strong>{data?.data?.employee_count}</strong>
|
<span className="font-medium text-dark dark:text-white">
|
||||||
|
{company.annual_revenue || "—"}
|
||||||
|
</span>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
<ShowcaseSection title={"Founded Year"}>
|
<ShowcaseSection title="Employee count" {...SHOWCASE_CENTERED_BODY}>
|
||||||
<strong>{data?.data?.founded_year}</strong>
|
<span className="font-medium text-dark dark:text-white">
|
||||||
|
{company.employee_count || "—"}
|
||||||
|
</span>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
<ShowcaseSection title={"Industry"}>
|
<ShowcaseSection
|
||||||
<strong>{data?.data?.industry}</strong>
|
title="Registration number"
|
||||||
|
{...SHOWCASE_CENTERED_BODY}
|
||||||
|
>
|
||||||
|
<span className="font-medium text-dark dark:text-white">
|
||||||
|
{company.registration_number || "—"}
|
||||||
|
</span>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
<ShowcaseSection title={"Is Compliant"}>
|
<ShowcaseSection title="Tax ID" {...SHOWCASE_CENTERED_BODY}>
|
||||||
<strong>{data?.data?.is_compliant ? "Yes" : "No"}</strong>
|
<span className="font-medium text-dark dark:text-white">
|
||||||
|
{company.tax_id || "—"}
|
||||||
|
</span>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
<ShowcaseSection title={"Is Verified"}>
|
<ShowcaseSection title="Compliant" {...SHOWCASE_CENTERED_BODY}>
|
||||||
<strong>{data?.data?.is_verified ? "Yes" : "No"}</strong>
|
<span className="font-medium text-dark dark:text-white">
|
||||||
|
{company.is_compliant ? "Yes" : "No"}
|
||||||
|
</span>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
<ShowcaseSection title={"Phone"}>
|
<ShowcaseSection title="Verified" {...SHOWCASE_CENTERED_BODY}>
|
||||||
<strong>{formatPhoneNumber(data?.data?.phone ?? 0)}</strong>
|
<span className="font-medium text-dark dark:text-white">
|
||||||
|
{company.is_verified ? "Yes" : "No"}
|
||||||
|
</span>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
<ShowcaseSection title={"Registration Number"}>
|
</div>
|
||||||
<strong>{data?.data?.registration_number}</strong>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionHeading>Contact & timeline</SectionHeading>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
<ShowcaseSection title="Email" {...SHOWCASE_CENTERED_BODY}>
|
||||||
|
<span className="break-all font-medium text-dark dark:text-white">
|
||||||
|
{company.email || "—"}
|
||||||
|
</span>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
<ShowcaseSection title={"Tax id"}>
|
<ShowcaseSection title="Phone" {...SHOWCASE_CENTERED_BODY}>
|
||||||
<strong>{data?.data?.tax_id}</strong>
|
<span className="font-medium text-dark dark:text-white">
|
||||||
|
{formatPhoneNumber(company.phone ?? 0)}
|
||||||
|
</span>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
<ShowcaseSection title={"Type"}>
|
<ShowcaseSection title="Created at" {...SHOWCASE_CENTERED_BODY}>
|
||||||
<strong>{data?.data?.type}</strong>
|
<time className="font-medium tabular-nums text-dark dark:text-white">
|
||||||
|
{unixToDate({ unix: company.created_at })}
|
||||||
|
</time>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
<ShowcaseSection title={"Updated at"}>
|
<ShowcaseSection title="Updated at" {...SHOWCASE_CENTERED_BODY}>
|
||||||
<strong>{unixToDate({ unix: data?.data?.updated_at ?? 0 })}</strong>
|
<time className="font-medium tabular-nums text-dark dark:text-white">
|
||||||
|
{unixToDate({ unix: company.updated_at ?? 0 })}
|
||||||
|
</time>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
<IsVisible condition={!!data.data.website}>
|
{website && (
|
||||||
<ShowcaseSection title={"Website"}>
|
<ShowcaseSection title="Website" {...SHOWCASE_CENTERED_BODY}>
|
||||||
<Link
|
<Link
|
||||||
href={data.data.website}
|
href={website}
|
||||||
className="font-bold"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex max-w-full items-center gap-1.5 break-all font-medium text-primary underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
{data.data.website}
|
<ExternalLinkIcon className="shrink-0" />
|
||||||
|
{website}
|
||||||
</Link>
|
</Link>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
</IsVisible>
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ interface IProps {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isValidDisplayCountryCode = (code: string) => /^[A-Za-z]{2,3}$/.test(code);
|
||||||
|
|
||||||
function SectionHeading({ children }: { children: React.ReactNode }) {
|
function SectionHeading({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 flex items-center gap-3">
|
<div className="mb-4 flex items-center gap-3">
|
||||||
@@ -65,9 +67,11 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
const { data, isLoading, isError } = useTenderDetailQuery(details);
|
const { data, isLoading, isError } = useTenderDetailQuery(details);
|
||||||
const countryCodeNormalized =
|
const countryCodeNormalized =
|
||||||
data?.data.country_code?.trim().toUpperCase() ?? "";
|
data?.data.country_code?.trim().toUpperCase() ?? "";
|
||||||
const countryCodeValid = isValidAlpha2CountryCode(countryCodeNormalized);
|
const hasValidCountryCodeForDisplay =
|
||||||
|
isValidDisplayCountryCode(countryCodeNormalized);
|
||||||
|
const canLoadCountryFlag = isValidAlpha2CountryCode(countryCodeNormalized);
|
||||||
const { data: flagData } = useGetFlagQuery(countryCodeNormalized, {
|
const { data: flagData } = useGetFlagQuery(countryCodeNormalized, {
|
||||||
enabled: countryCodeValid,
|
enabled: canLoadCountryFlag,
|
||||||
});
|
});
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ name: "Dashboard", href: "/" },
|
{ name: "Dashboard", href: "/" },
|
||||||
@@ -103,6 +107,14 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
|
|
||||||
const tender = data!.data;
|
const tender = data!.data;
|
||||||
const submissionUrl = tender.submission_url?.trim();
|
const submissionUrl = tender.submission_url?.trim();
|
||||||
|
const currencyCode = tender.currency?.trim().toUpperCase() ?? "";
|
||||||
|
const hasValidCurrency = Boolean(currencyCode);
|
||||||
|
const hasValidEstimatedValue =
|
||||||
|
typeof tender.estimated_value === "number" &&
|
||||||
|
Number.isFinite(tender.estimated_value) &&
|
||||||
|
tender.estimated_value > 0;
|
||||||
|
const hasValidLocationCurrencyOrValue =
|
||||||
|
hasValidCountryCodeForDisplay || hasValidCurrency || hasValidEstimatedValue;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -252,9 +264,11 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasValidLocationCurrencyOrValue && (
|
||||||
<div>
|
<div>
|
||||||
<SectionHeading>Location & currency</SectionHeading>
|
<SectionHeading>Location & currency</SectionHeading>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:max-w-3xl">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:max-w-5xl">
|
||||||
|
{hasValidCountryCodeForDisplay && (
|
||||||
<div className="shadow-theme-xs relative overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-gray-2/30 to-primary/[0.06] p-5 dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-primary/[0.12]">
|
<div className="shadow-theme-xs relative overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-gray-2/30 to-primary/[0.06] p-5 dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-primary/[0.12]">
|
||||||
<div className="pointer-events-none absolute -right-8 top-0 h-24 w-24 rounded-full bg-primary/15 blur-2xl dark:bg-primary/25" />
|
<div className="pointer-events-none absolute -right-8 top-0 h-24 w-24 rounded-full bg-primary/15 blur-2xl dark:bg-primary/25" />
|
||||||
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
||||||
@@ -266,7 +280,7 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm font-semibold uppercase tracking-wide text-dark dark:text-white">
|
<span className="text-sm font-semibold uppercase tracking-wide text-dark dark:text-white">
|
||||||
{tender.country_code}
|
{countryCodeNormalized}
|
||||||
</span>
|
</span>
|
||||||
{flagData && (
|
{flagData && (
|
||||||
<div
|
<div
|
||||||
@@ -277,25 +291,46 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{tender.currency && (
|
{hasValidCurrency && (
|
||||||
<div className="shadow-theme-xs relative overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-gray-2/30 to-purple-500/[0.06] p-5 dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-purple-500/[0.12]">
|
<div className="shadow-theme-xs relative overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-gray-2/30 to-blue/[0.06] p-5 dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-blue/[0.12]">
|
||||||
<div className="pointer-events-none absolute -left-6 bottom-0 h-20 w-20 rounded-full bg-purple-500/15 blur-2xl dark:bg-purple-500/25" />
|
<div className="pointer-events-none absolute -left-6 bottom-0 h-20 w-20 rounded-full bg-blue/15 blur-2xl dark:bg-blue/25" />
|
||||||
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
||||||
Currency
|
Currency
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-2xl font-bold tabular-nums text-dark dark:text-white">
|
<span className="text-2xl font-bold tabular-nums text-dark dark:text-white">
|
||||||
{tender.currency}
|
{currencyCode}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-lg font-medium text-dark-5 dark:text-dark-6">
|
<span className="text-lg font-medium text-dark-5 dark:text-dark-6">
|
||||||
{getSymbolFromCurrency(tender.currency ?? "USD")}
|
{getSymbolFromCurrency(currencyCode || "USD")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasValidEstimatedValue && (
|
||||||
|
<div className="shadow-theme-xs relative overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-gray-2/30 to-green/[0.06] p-5 dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-green/[0.12]">
|
||||||
|
<div className="pointer-events-none absolute -right-6 bottom-0 h-20 w-20 rounded-full bg-green/15 blur-2xl dark:bg-green/25" />
|
||||||
|
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
||||||
|
Estimated value
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-2xl font-bold tabular-nums text-dark dark:text-white">
|
||||||
|
{new Intl.NumberFormat().format(tender.estimated_value)}
|
||||||
|
</span>
|
||||||
|
{hasValidCurrency && (
|
||||||
|
<span className="text-sm font-medium uppercase text-dark-5 dark:text-dark-6">
|
||||||
|
{currencyCode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<SectionHeading>Description</SectionHeading>
|
<SectionHeading>Description</SectionHeading>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type PropsType<T extends FieldValues> = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
|
dataCy?: string;
|
||||||
handleChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
handleChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||||
register?: UseFormRegister<T>;
|
register?: UseFormRegister<T>;
|
||||||
errors?: FieldErrors<T>;
|
errors?: FieldErrors<T>;
|
||||||
@@ -33,6 +34,7 @@ export function TextAreaGroup<T extends FieldValues>({
|
|||||||
className,
|
className,
|
||||||
icon,
|
icon,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
|
dataCy,
|
||||||
handleChange,
|
handleChange,
|
||||||
register,
|
register,
|
||||||
errors,
|
errors,
|
||||||
@@ -77,6 +79,7 @@ export function TextAreaGroup<T extends FieldValues>({
|
|||||||
required={required}
|
required={required}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
data-active={active}
|
data-active={active}
|
||||||
|
data-cy={dataCy}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{icon}
|
{icon}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type PropsType = {
|
|||||||
backgroundSize?: "sm" | "default";
|
backgroundSize?: "sm" | "default";
|
||||||
name?: string;
|
name?: string;
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
|
dataCy?: string;
|
||||||
onToggle: (e: ToggleEvent<HTMLInputElement>) => void;
|
onToggle: (e: ToggleEvent<HTMLInputElement>) => void;
|
||||||
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
|
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||||
};
|
};
|
||||||
@@ -18,6 +19,7 @@ export function Switch({
|
|||||||
backgroundSize,
|
backgroundSize,
|
||||||
name,
|
name,
|
||||||
checked = false,
|
checked = false,
|
||||||
|
dataCy,
|
||||||
onToggle,
|
onToggle,
|
||||||
onChange,
|
onChange,
|
||||||
}: PropsType) {
|
}: PropsType) {
|
||||||
@@ -37,6 +39,7 @@ export function Switch({
|
|||||||
id={id}
|
id={id}
|
||||||
className="peer sr-only"
|
className="peer sr-only"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
|
data-cy={dataCy}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn("h-8 w-14 rounded-full bg-gray-3 dark:bg-[#5A616B]", {
|
className={cn("h-8 w-14 rounded-full bg-gray-3 dark:bg-[#5A616B]", {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const CompanyCategoryListFilters = ({
|
|||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
|
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
|
||||||
|
data-cy="company-categories-filter-form"
|
||||||
onSubmit={handleFilterSubmit(search)}
|
onSubmit={handleFilterSubmit(search)}
|
||||||
>
|
>
|
||||||
<InputGroup
|
<InputGroup
|
||||||
@@ -38,6 +39,7 @@ const CompanyCategoryListFilters = ({
|
|||||||
label="search"
|
label="search"
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
type="search"
|
type="search"
|
||||||
|
dataCy="company-categories-filter-search-input"
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
{...filterRegister("published")}
|
{...filterRegister("published")}
|
||||||
@@ -51,11 +53,13 @@ const CompanyCategoryListFilters = ({
|
|||||||
clearable
|
clearable
|
||||||
value={watch("published")}
|
value={watch("published")}
|
||||||
onClear={() => setValue("published", undefined)}
|
onClear={() => setValue("published", undefined)}
|
||||||
|
dataCy="company-categories-filter-published-select"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
data-cy="company-categories-filter-submit-button"
|
||||||
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
||||||
>
|
>
|
||||||
{isMutating ? (
|
{isMutating ? (
|
||||||
@@ -66,6 +70,7 @@ const CompanyCategoryListFilters = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-cy="company-categories-filter-close-button"
|
||||||
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
||||||
onClick={() => setIsFilterModalOpen(false)}
|
onClick={() => setIsFilterModalOpen(false)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -47,12 +47,13 @@ const CompanyCategoriesTable = () => {
|
|||||||
} = useCompanyCategoriesPresenter();
|
} = useCompanyCategoriesPresenter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListWrapper>
|
<ListWrapper data-cy="company-categories-page">
|
||||||
<ListHeader
|
<ListHeader
|
||||||
onFilter={() => setIsFilterModalOpen(true)}
|
onFilter={() => setIsFilterModalOpen(true)}
|
||||||
createButtonText="Create Category"
|
createButtonText="Create Category"
|
||||||
|
dataCyPrefix="company-categories"
|
||||||
/>
|
/>
|
||||||
<Table>
|
<Table data-cy="company-categories-table">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-none uppercase [&>th]:text-start">
|
<TableRow className="border-none uppercase [&>th]:text-start">
|
||||||
<TableHead colSpan={100}>Row</TableHead>
|
<TableHead colSpan={100}>Row</TableHead>
|
||||||
@@ -73,6 +74,7 @@ const CompanyCategoriesTable = () => {
|
|||||||
{optimisticCategories.map((category, index) => (
|
{optimisticCategories.map((category, index) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={category.id}
|
key={category.id}
|
||||||
|
data-cy={`company-categories-table-row-${category.id}`}
|
||||||
className="odd:bg-gray-2 dark:odd:bg-gray-7"
|
className="odd:bg-gray-2 dark:odd:bg-gray-7"
|
||||||
>
|
>
|
||||||
<TableCell colSpan={100}>
|
<TableCell colSpan={100}>
|
||||||
@@ -84,8 +86,10 @@ const CompanyCategoriesTable = () => {
|
|||||||
{unixToDate({ unix: category.created_at, hasTime: true })}
|
{unixToDate({ unix: category.created_at, hasTime: true })}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell colSpan={100}>
|
<TableCell colSpan={100}>
|
||||||
|
{/* Published: flip UI immediately via useOptimistic; if togglePublished rejects, React rolls the optimistic row back. */}
|
||||||
<Switch
|
<Switch
|
||||||
withIcon
|
withIcon
|
||||||
|
dataCy={`company-categories-published-toggle-${category.id}`}
|
||||||
onToggle={() => {}}
|
onToggle={() => {}}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
const nextPublished = !category.published;
|
const nextPublished = !category.published;
|
||||||
@@ -94,7 +98,11 @@ const CompanyCategoriesTable = () => {
|
|||||||
id: category.id,
|
id: category.id,
|
||||||
published: nextPublished,
|
published: nextPublished,
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
await togglePublished(category.id);
|
await togglePublished(category.id);
|
||||||
|
} catch {
|
||||||
|
// useOptimistic automatically reverts when the transition ends
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
checked={category.published}
|
checked={category.published}
|
||||||
@@ -103,6 +111,8 @@ const CompanyCategoriesTable = () => {
|
|||||||
<TableCell className="text-start xl:pr-7.5">
|
<TableCell className="text-start xl:pr-7.5">
|
||||||
<div className="flex items-center justify-start gap-x-3.5">
|
<div className="flex items-center justify-start gap-x-3.5">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
data-cy={`company-categories-row-delete-${category.id}`}
|
||||||
data-tooltip-id="delete"
|
data-tooltip-id="delete"
|
||||||
data-tooltip-content="Delete"
|
data-tooltip-content="Delete"
|
||||||
data-tooltip-place="top"
|
data-tooltip-place="top"
|
||||||
@@ -117,6 +127,8 @@ const CompanyCategoriesTable = () => {
|
|||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
data-cy={`company-categories-row-edit-${category.id}`}
|
||||||
data-tooltip-id="edit"
|
data-tooltip-id="edit"
|
||||||
data-tooltip-content="Edit"
|
data-tooltip-content="Edit"
|
||||||
data-tooltip-place="top"
|
data-tooltip-place="top"
|
||||||
|
|||||||
@@ -87,6 +87,12 @@ const TendersTable = () => {
|
|||||||
<TableCell className="text-start" colSpan={100}>
|
<TableCell className="text-start" colSpan={100}>
|
||||||
{item.country_code}
|
{item.country_code}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-start" colSpan={100}>
|
||||||
|
{unixToDate({
|
||||||
|
unix: item.publication_date,
|
||||||
|
hasTime: true,
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-start" colSpan={100}>
|
<TableCell className="text-start" colSpan={100}>
|
||||||
{unixToDate({
|
{unixToDate({
|
||||||
unix: item.submission_deadline,
|
unix: item.submission_deadline,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const useTenderListPresenter = () => {
|
|||||||
"row",
|
"row",
|
||||||
"title",
|
"title",
|
||||||
"country code",
|
"country code",
|
||||||
|
"publication date",
|
||||||
"submission deadline",
|
"submission deadline",
|
||||||
"tender deadline",
|
"tender deadline",
|
||||||
"created at",
|
"created at",
|
||||||
|
|||||||
@@ -17,15 +17,20 @@ const CreateCompanyCategoryForm = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
const { errors, handleSubmit, onSubmit, register } =
|
const { errors, handleSubmit, onSubmit, register } =
|
||||||
useCreateCompanyCategoryPresenter({ defaultValues, editMode, id });
|
useCreateCompanyCategoryPresenter({ defaultValues, editMode, id });
|
||||||
return (
|
return (
|
||||||
<form className="grid grid-cols-1 gap-9" onSubmit={handleSubmit(onSubmit)}>
|
<form
|
||||||
|
className="grid grid-cols-1 gap-9"
|
||||||
|
data-cy="company-category-form"
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
<ShowcaseSection
|
<ShowcaseSection
|
||||||
title="Create Company Category"
|
title={editMode ? "Edit Company Category" : "Create Company Category"}
|
||||||
className="grid grid-cols-1 gap-5 md:grid-cols-2"
|
className="grid grid-cols-1 gap-5 md:grid-cols-2"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2 md:col-span-1">
|
<div className="flex flex-col gap-2 md:col-span-1">
|
||||||
<InputGroup
|
<InputGroup
|
||||||
label="Name"
|
label="Name"
|
||||||
required
|
required
|
||||||
|
dataCy="company-category-form-name-input"
|
||||||
{...register("name", {
|
{...register("name", {
|
||||||
required: {
|
required: {
|
||||||
value: true,
|
value: true,
|
||||||
@@ -51,6 +56,7 @@ const CreateCompanyCategoryForm = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
<div className="flex flex-col gap-2 md:col-span-1">
|
<div className="flex flex-col gap-2 md:col-span-1">
|
||||||
<TextAreaGroup
|
<TextAreaGroup
|
||||||
label="Description"
|
label="Description"
|
||||||
|
dataCy="company-category-form-description-input"
|
||||||
{...register("description", {
|
{...register("description", {
|
||||||
minLength: {
|
minLength: {
|
||||||
value: 10,
|
value: 10,
|
||||||
|
|||||||
Reference in New Issue
Block a user