This commit is contained in:
@@ -5,6 +5,7 @@ import DocumentItem from "@/components/forms/companies/documents/DocumentItem";
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
import Status from "@/components/ui/Status";
|
||||
import { useCompanyDetails } from "@/hooks/queries";
|
||||
import type { ICompanyLink } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BreadcrumbItem } from "@/types/shared";
|
||||
import { formatPhoneNumber, unixToDate } from "@/utils/shared";
|
||||
@@ -76,6 +77,9 @@ const CompanyDetailsPage = ({ params }: IProps) => {
|
||||
const documentFileIds: string[] = Array.isArray(company.document_file_ids)
|
||||
? company.document_file_ids
|
||||
: [];
|
||||
const companyLinks: ICompanyLink[] = Array.isArray(company.links)
|
||||
? company.links
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -145,12 +149,18 @@ const CompanyDetailsPage = ({ params }: IProps) => {
|
||||
{`${company.currency || "—"} ${getSymbolFromCurrency(company.currency ?? "USD")}`}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
<ShowcaseSection title="Annual revenue" {...SHOWCASE_CENTERED_BODY}>
|
||||
<ShowcaseSection
|
||||
title="Annual revenue"
|
||||
{...SHOWCASE_CENTERED_BODY}
|
||||
>
|
||||
<span className="font-medium text-dark dark:text-white">
|
||||
{company.annual_revenue || "—"}
|
||||
</span>
|
||||
</ShowcaseSection>
|
||||
<ShowcaseSection title="Employee count" {...SHOWCASE_CENTERED_BODY}>
|
||||
<ShowcaseSection
|
||||
title="Employee count"
|
||||
{...SHOWCASE_CENTERED_BODY}
|
||||
>
|
||||
<span className="font-medium text-dark dark:text-white">
|
||||
{company.employee_count || "—"}
|
||||
</span>
|
||||
@@ -230,6 +240,35 @@ const CompanyDetailsPage = ({ params }: IProps) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{companyLinks.length > 0 && (
|
||||
<div>
|
||||
<SectionHeading>Links</SectionHeading>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{companyLinks.map((companyLink, index) => (
|
||||
<Link
|
||||
key={`${companyLink.url}-${index}`}
|
||||
href={companyLink.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shadow-theme-xs flex min-w-0 items-center justify-between gap-3 rounded-xl border border-stroke/70 bg-white/80 px-4 py-3 text-primary transition hover:border-primary/50 hover:bg-primary/5 dark:border-dark-3 dark:bg-dark-2/70 dark:hover:border-primary/50"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium text-dark dark:text-white">
|
||||
{companyLink.title || companyLink.url}
|
||||
</span>
|
||||
{companyLink.title ? (
|
||||
<span className="block truncate text-xs text-dark-5 dark:text-dark-6">
|
||||
{companyLink.url}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<ExternalLinkIcon />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ShowcaseSection>
|
||||
</>
|
||||
|
||||
@@ -54,7 +54,7 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
{editMode ? "Edit company" : "New company"}
|
||||
</h1>
|
||||
<p className="text-sm text-dark-5 dark:text-dark-6">
|
||||
Profile, business details, address, tags, and supporting documents.
|
||||
Profile, business details, address, tags, documents, and links.
|
||||
</p>
|
||||
</header>
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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();
|
||||
@@ -18,6 +19,7 @@ const settingsSectionId = uuidv4();
|
||||
const addressSectionId = uuidv4();
|
||||
const tagsSectionId = uuidv4();
|
||||
const documentsSectionId = uuidv4();
|
||||
const linksSectionId = uuidv4();
|
||||
|
||||
interface CompanyFormSectionsOptions {
|
||||
editMode?: boolean;
|
||||
@@ -169,9 +171,8 @@ const getTimezoneOffsetMinutes = (timezone: string, date = new Date()) => {
|
||||
timeZoneName: "shortOffset",
|
||||
});
|
||||
const offset =
|
||||
formatter
|
||||
.formatToParts(date)
|
||||
.find((part) => part.type === "timeZoneName")?.value ?? "";
|
||||
formatter.formatToParts(date).find((part) => part.type === "timeZoneName")
|
||||
?.value ?? "";
|
||||
const label = offset.replace(/^GMT/, "") || "+0";
|
||||
const match = label.match(/^([+-])(\d{1,2})(?::(\d{2}))?$/);
|
||||
|
||||
@@ -667,4 +668,19 @@ export const createCompanyFormSections = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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,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;
|
||||
@@ -30,6 +30,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
links: defaultValues?.links ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -72,6 +73,10 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
|
||||
employee_count: optionalNumber(data.employee_count),
|
||||
annual_revenue: optionalNumber(data.annual_revenue),
|
||||
document_file_ids: documentFileIds ?? [],
|
||||
links: (data.links ?? []).map((link) => ({
|
||||
url: link.url.trim(),
|
||||
...(link.title?.trim() ? { title: link.title.trim() } : {}),
|
||||
})),
|
||||
tags: {
|
||||
keywords: data?.tags?.keywords?.length ? data?.tags?.keywords : [],
|
||||
categories: data?.tags?.categories?.length ? data?.tags?.categories : [],
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
API_ENDPOINTS,
|
||||
companiesService,
|
||||
companyCategoriesService,
|
||||
type ICompanyLink,
|
||||
type TCompanyCategoryApiResponse,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
@@ -100,6 +101,26 @@ export const useUploadCompanyDocuments = (id: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useAddCompanyLinks = (id: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
const mutationKey = useMemo(
|
||||
() => [API_ENDPOINTS.COMPANIES.LINKS(id), "add-links"],
|
||||
[id],
|
||||
);
|
||||
|
||||
return useMutation({
|
||||
mutationKey,
|
||||
mutationFn: (links: ICompanyLink[]) =>
|
||||
companiesService.addLinks({ id, links }),
|
||||
onSuccess: (response) => {
|
||||
toast.success(response.message ?? "Company links added successfully");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL, id, "details"],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteCompanyQuery = (successCallback?: () => void) => {
|
||||
const queryClient = useQueryClient();
|
||||
const mutationKey = useMemo(
|
||||
@@ -224,7 +245,8 @@ export const useDeleteCompanyCategoryQuery = (successCallback?: () => void) => {
|
||||
};
|
||||
export const useToggleCompanyCategoryPublished = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const updateOptimisticCategories = useCallback((categoryId: string) => {
|
||||
const updateOptimisticCategories = useCallback(
|
||||
(categoryId: string) => {
|
||||
queryClient.setQueriesData<TCompanyCategoryApiResponse>(
|
||||
{
|
||||
queryKey: [API_ENDPOINTS.COMPANIES.CATEGORIES.READ_ALL],
|
||||
@@ -247,7 +269,9 @@ export const useToggleCompanyCategoryPublished = () => {
|
||||
};
|
||||
},
|
||||
);
|
||||
}, [queryClient]);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
const mutationKey = useMemo(
|
||||
() => [
|
||||
API_ENDPOINTS.COMPANIES.CATEGORIES.TOGGLE_PUBLISHED("published"),
|
||||
@@ -263,8 +287,7 @@ export const useToggleCompanyCategoryPublished = () => {
|
||||
data?: { message?: string };
|
||||
message?: string;
|
||||
};
|
||||
const toastMessage =
|
||||
responseBody.data?.message ?? responseBody.message;
|
||||
const toastMessage = responseBody.data?.message ?? responseBody.message;
|
||||
if (toastMessage) {
|
||||
toast.success(toastMessage);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const API_ENDPOINTS = {
|
||||
DELETE: (id: string) => `companies/${id}`,
|
||||
DETAILS: (id: string) => `companies/${id}`,
|
||||
DOCUMENTS: (id: string) => `companies/${id}/documents`,
|
||||
LINKS: (id: string) => `companies/${id}/links`,
|
||||
CATEGORIES: {
|
||||
READ_ALL: "company-categories",
|
||||
CREATE: "company-categories",
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
TCompaniesResponse,
|
||||
TCompanyCategoryApiResponse,
|
||||
TCreateCompanyCategoryCredentials,
|
||||
IAddCompanyLinksResponse,
|
||||
ICompanyLink,
|
||||
UploadDocumentsData,
|
||||
} from "../types";
|
||||
|
||||
@@ -87,10 +89,25 @@ export const companiesService = {
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("ERROR caught in Companies Services Upload Documents:", error);
|
||||
console.error(
|
||||
"ERROR caught in Companies Services Upload Documents:",
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
addLinks: async ({
|
||||
id,
|
||||
links,
|
||||
}: {
|
||||
id: string;
|
||||
links: ICompanyLink[];
|
||||
}): Promise<ApiResponse<IAddCompanyLinksResponse>> => {
|
||||
const response = await api.post(API_ENDPOINTS.COMPANIES.LINKS(id), {
|
||||
links,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
deleteCompany: async (id: string) => {
|
||||
try {
|
||||
return (await api.delete(API_ENDPOINTS.COMPANIES.DELETE(id))).data;
|
||||
|
||||
@@ -28,6 +28,11 @@ export interface ITags {
|
||||
cpv_codes: string[];
|
||||
}
|
||||
|
||||
export interface ICompanyLink {
|
||||
url: string;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
export interface ICreateCompanyCredentials {
|
||||
name: string;
|
||||
email: string;
|
||||
@@ -48,6 +53,7 @@ export interface ICreateCompanyCredentials {
|
||||
contact_person: IContactPerson;
|
||||
tags: ITags;
|
||||
document_file_ids?: string[];
|
||||
links?: ICompanyLink[];
|
||||
}
|
||||
|
||||
export const companySchema = z.object({
|
||||
@@ -92,6 +98,14 @@ export const companySchema = z.object({
|
||||
cpv_codes: z.array(z.string()).optional(),
|
||||
}),
|
||||
document_file_ids: z.array(z.string()).optional(),
|
||||
links: z
|
||||
.array(
|
||||
z.object({
|
||||
url: z.string().url(),
|
||||
title: z.string().nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const companiesResponseSchema = z.object({
|
||||
|
||||
@@ -38,6 +38,16 @@ export interface ITags {
|
||||
cpv_codes: string[];
|
||||
}
|
||||
|
||||
export interface ICompanyLink {
|
||||
url: string;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
export interface IAddCompanyLinksResponse {
|
||||
links: ICompanyLink[];
|
||||
added: ICompanyLink[];
|
||||
}
|
||||
|
||||
export interface ICreateCompanyCredentials {
|
||||
name: string;
|
||||
email: string;
|
||||
@@ -58,6 +68,7 @@ export interface ICreateCompanyCredentials {
|
||||
contact_person?: IContactPerson;
|
||||
tags: ITags;
|
||||
document_file_ids?: string[];
|
||||
links?: ICompanyLink[];
|
||||
}
|
||||
|
||||
export const companySchema = z.object({
|
||||
@@ -110,6 +121,15 @@ export const companySchema = z.object({
|
||||
cpv_codes: nullableStringArraySchema,
|
||||
}),
|
||||
document_file_ids: z.array(z.string()).or(z.null()).optional(),
|
||||
links: z
|
||||
.array(
|
||||
z.object({
|
||||
url: z.string().url(),
|
||||
title: z.string().nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.or(z.null())
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const companiesResponseSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user