diff --git a/apps/api/src/modules/vacancies/graphql/vacancy.resolver.ts b/apps/api/src/modules/vacancies/graphql/vacancy.resolver.ts index 961e32e..b304ade 100644 --- a/apps/api/src/modules/vacancies/graphql/vacancy.resolver.ts +++ b/apps/api/src/modules/vacancies/graphql/vacancy.resolver.ts @@ -13,6 +13,7 @@ import { Args, Mutation, Parent, + Query, ResolveField, Resolver, } from "@nestjs/graphql"; @@ -48,6 +49,16 @@ export class VacancyResolver { private readonly locationDataLoader: LocationDataLoaderService, ) {} + @Query(() => Vacancy) + async vacancy( + @CurrentUser() user: DomainUser, + @Args("id") id: string, + ): Promise { + const vacancy = await this.vacancyService.findByIdOrFail(id); + await this.authorizationService.canView(user, vacancy, VacancyEntity); + return Vacancy.fromDomain(vacancy); + } + @Mutation(() => Vacancy) async createVacancy( @CurrentUser() user: DomainUser, diff --git a/apps/client/src/features/vacancies/components/VacancyList.tsx b/apps/client/src/features/vacancies/components/VacancyList.tsx index b5f915f..1b6ff6b 100644 --- a/apps/client/src/features/vacancies/components/VacancyList.tsx +++ b/apps/client/src/features/vacancies/components/VacancyList.tsx @@ -1,3 +1,4 @@ +import { ViewTransitionLink } from "@cv/routing"; import { DeleteIcon, EditIcon, @@ -103,12 +104,15 @@ export const VacancyList = ({ vacancies, onDelete }: VacancyListProps) => { {vacancies.map((vacancy) => ( -
+
{vacancy.title}
{vacancy.company?.name ?? "—"}
-
+
{vacancy.location?.name ?? "—"}
diff --git a/apps/client/src/features/vacancies/queries/vacancy.graphql b/apps/client/src/features/vacancies/queries/vacancy.graphql new file mode 100644 index 0000000..84ff039 --- /dev/null +++ b/apps/client/src/features/vacancies/queries/vacancy.graphql @@ -0,0 +1,46 @@ +query Vacancy($id: String!) { + vacancy(id: $id) { + id + title + ownerId + description + requirements + minSalary + maxSalary + applicationUrl + deadline + isActive + isPublic + createdAt + updatedAt + company { + id + name + } + role { + id + name + } + level { + id + name + } + location { + id + name + type + } + contractType { + id + name + } + rateType { + id + name + } + skills { + id + name + } + } +} diff --git a/apps/client/src/pages/vacancies/VacancyDetailPage.tsx b/apps/client/src/pages/vacancies/VacancyDetailPage.tsx new file mode 100644 index 0000000..05f130f --- /dev/null +++ b/apps/client/src/pages/vacancies/VacancyDetailPage.tsx @@ -0,0 +1,131 @@ +import { ViewTransitionLink } from "@cv/routing"; +import { Badge, Button, Card, Page, Placeholder, StatusBadge } from "@cv/ui"; +import { useParams } from "react-router-dom"; +import { useVacancyQuery } from "@/generated/graphql"; +import { formatDeadline } from "@/utils/dateUtils"; +import { formatSalary } from "@/utils/salaryFormatter"; + +const Section = ({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) => ( +
+

+ {title} +

+
{children}
+
+); + +const VacancyDetailPage = () => { + const { id } = useParams<{ id: string }>(); + const { data, isLoading, error } = useVacancyQuery( + { id: id ?? "" }, + { enabled: !!id }, + ); + + const title = "Vacancy"; + + if (isLoading) { + return ( + + + + ); + } + + if (error || !data?.vacancy) { + return ( + + + + + + + + ); + } + + const vacancy = data.vacancy; + const salary = formatSalary(vacancy.minSalary, vacancy.maxSalary); + + return ( + + + + } + > + +
+ + {vacancy.role && {vacancy.role.name}} + {vacancy.level && ( + {vacancy.level.name} + )} + {vacancy.contractType && ( + {vacancy.contractType.name} + )} + {vacancy.rateType && ( + {vacancy.rateType.name} + )} +
+ +
+
{vacancy.company?.name ?? "-"}
+
{vacancy.location?.name ?? "-"}
+
{salary ?? "-"}
+
{formatDeadline(vacancy.deadline)}
+
+ + {vacancy.skills && vacancy.skills.length > 0 && ( +
+
+ {vacancy.skills.map((skill) => ( + + {skill.name} + + ))} +
+
+ )} + + {vacancy.description && ( +
+

{vacancy.description}

+
+ )} + + {vacancy.requirements && ( +
+

{vacancy.requirements}

+
+ )} + + {vacancy.applicationUrl && ( +
+ + {vacancy.applicationUrl} + +
+ )} +
+
+ ); +}; + +export default VacancyDetailPage; diff --git a/apps/client/src/router/AppRouter.tsx b/apps/client/src/router/AppRouter.tsx index 2b0119d..ae7a8aa 100644 --- a/apps/client/src/router/AppRouter.tsx +++ b/apps/client/src/router/AppRouter.tsx @@ -52,6 +52,9 @@ const AdminPage = lazy(() => import("@/pages/admin/AdminPage")); const AsyncJobsPage = lazy(() => import("@/pages/admin/AsyncJobsPage")); const SystemStatusPage = lazy(() => import("@/pages/admin/SystemStatusPage")); const VacanciesPage = lazy(() => import("@/pages/vacancies/VacanciesPage")); +const VacancyDetailPage = lazy( + () => import("@/pages/vacancies/VacancyDetailPage"), +); const AppRouter = () => { const { isAuthenticated, isLoading } = useToken(); @@ -241,6 +244,14 @@ const AppRouter = () => { } /> + }> + + + } + />