feat: add public employee profile view and implement ratings review page components

This commit is contained in:
2026-07-22 16:27:46 -03:00
parent 279e1b54cc
commit dd3e07311d
27 changed files with 1621 additions and 304 deletions
@@ -0,0 +1,114 @@
.professionals {
box-sizing: border-box;
width: 100%;
margin: 4px 0 30px;
}
.header {
margin: 0 10px 5px;
}
.header h2 {
margin: 0;
padding-bottom: 10px;
color: #241f1b;
font-size: 1.5rem;
font-weight: 700;
line-height: 1.25;
}
.header p {
max-width: 620px;
margin: 0 0 10px;
color: #6d6258;
font-size: 0.95rem;
line-height: 1.45;
}
.scroller {
display: flex;
gap: 20px;
width: 100%;
padding: 0 10px 12px;
overflow-x: auto;
cursor: grab;
scroll-padding-left: 10px;
scroll-snap-type: x mandatory;
scrollbar-width: thin;
user-select: none;
}
.dragging {
cursor: grabbing;
}
.card {
display: flex;
flex: 0 0 min(300px, calc(100vw - 40px));
align-items: center;
gap: 16px;
min-height: 116px;
padding: 20px;
border: 1px solid rgba(45, 29, 21, 0.1);
border-radius: 18px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 10px 24px rgba(45, 29, 21, 0.06);
color: inherit;
scroll-snap-align: start;
text-decoration: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.card:hover {
border-color: rgba(116, 35, 59, 0.28);
box-shadow: 0 14px 30px rgba(45, 29, 21, 0.1);
transform: translateY(-1px);
}
.cardBody {
min-width: 0;
}
.cardBody h3 {
overflow: hidden;
margin: 0 0 10px;
color: #342d27;
font-size: 1.05rem;
font-weight: 700;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.rating {
display: flex;
align-items: center;
gap: 10px;
}
.stars {
color: #e8a533;
font-size: 1.25rem;
letter-spacing: 0.06em;
line-height: 1;
}
.score {
color: #342d27;
font-size: 0.95rem;
font-weight: 700;
line-height: 1;
}
.emptyRating {
margin: 0;
color: #8b8178;
font-size: 0.9rem;
font-weight: 600;
}
@media (max-width: 700px) {
.card {
flex-basis: calc(100vw - 40px);
}
}
@@ -0,0 +1,157 @@
"use client";
import { useEffect, useRef, useState, type PointerEvent, type ReactElement } from "react";
import classNames from "classnames";
import Link from "next/link";
import Avatar from "@components/Avatar/Avatar";
import { PublicCompanyEmployeeView } from "@core/Models/Employees.model";
import { RatingTargetSummaryResult, RatingTargetType } from "@core/Models/Ratings.model";
import { findPublicCompanyEmployees } from "@services/Employees.Service";
import { ratingTargetSummary } from "@services/Ratings.Service";
import style from "./OrganizationProfessionals.module.css";
interface OrganizationProfessionalsProps {
companyId: string;
title?: string;
subtitle?: string;
className?: string;
}
type ProfessionalWithRating = PublicCompanyEmployeeView & {
rating: RatingTargetSummaryResult | null;
};
const renderStars = (score: number): string => {
const normalizedScore = Math.min(Math.max(Math.round(score), 1), 5);
return "★".repeat(normalizedScore) + "☆".repeat(5 - normalizedScore);
};
const formatScore = (score: number): string => score.toFixed(1);
const getFullName = (professional: PublicCompanyEmployeeView): string => {
return [professional.firstName, professional.lastName].filter(Boolean).join(" ").trim();
};
export default function OrganizationProfessionals({
companyId,
title = "Profesionales de la organización",
subtitle = "Conocé a quienes atienden y sus opiniones.",
className,
}: OrganizationProfessionalsProps): ReactElement | null {
const [professionals, setProfessionals] = useState<ProfessionalWithRating[]>([]);
const [isDragging, setIsDragging] = useState(false);
const scrollerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let active = true;
findPublicCompanyEmployees({ companyId })
.then(async (employees) => {
const employeesWithRatings = await Promise.all(
employees.map(async (employee) => {
try {
const rating = await ratingTargetSummary({
targetType: RatingTargetType.EMPLOYEE,
targetId: employee.employeeId,
});
return { ...employee, rating };
} catch {
return { ...employee, rating: null };
}
})
);
if (active) {
setProfessionals(employeesWithRatings);
}
})
.catch(() => {
if (active) {
setProfessionals([]);
}
});
return () => {
active = false;
};
}, [companyId]);
const handlePointerDown = () => {
setIsDragging(true);
};
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
const scroller = scrollerRef.current;
if (!isDragging || !scroller) {
return;
}
scroller.scrollLeft -= event.movementX;
};
const stopDragging = () => {
setIsDragging(false);
};
if (professionals.length === 0) {
return null;
}
return (
<section className={classNames(style.professionals, className)} aria-label="Profesionales">
<div className={style.header}>
<h2>{title}</h2>
{subtitle && <p>{subtitle}</p>}
</div>
<div
ref={scrollerRef}
className={classNames(style.scroller, { [style.dragging]: isDragging })}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={stopDragging}
onPointerLeave={stopDragging}
>
{professionals.map((professional) => {
const fullName = getFullName(professional) || "Profesional";
const hasRating = Boolean(professional.rating && professional.rating.totalCount > 0);
return (
<Link
className={style.card}
href={`/landing/reviews?targetType=employee&targetId=${professional.employeeId}`}
key={professional.employeeId}
>
<Avatar
name={`professional-${professional.employeeId}`}
src={professional.avatarUrl || fullName}
alt={`Avatar de ${fullName}`}
size="medium"
border="none"
/>
<div className={style.cardBody}>
<h3>{fullName}</h3>
{hasRating ? (
<div
className={style.rating}
aria-label={`${formatScore(professional.rating!.averageScore)} de 5 estrellas`}
>
<span className={style.stars} aria-hidden="true">
{renderStars(professional.rating!.averageScore)}
</span>
<span className={style.score}>
{formatScore(professional.rating!.averageScore)}
</span>
</div>
) : (
<p className={style.emptyRating}>Sin opiniones aún</p>
)}
</div>
</Link>
);
})}
</div>
</section>
);
}
@@ -8,6 +8,13 @@
margin: 0 10px 5px;
}
.titleRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.header h2 {
margin: 0;
padding-bottom: 10px;
@@ -17,6 +24,19 @@
line-height: 1.25;
}
.viewMore {
flex: 0 0 auto;
margin-top: 4px;
color: var(--wine-red);
font-size: 0.95rem;
font-weight: 700;
text-decoration: none;
}
.viewMore:hover {
text-decoration: underline;
}
.header p {
max-width: 620px;
margin: 0 0 10px;
@@ -109,6 +129,10 @@
}
@media (max-width: 700px) {
.titleRow {
align-items: flex-start;
}
.reviewCard {
flex-basis: calc(100vw - 40px);
}
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState, type PointerEvent, type ReactElement } from "react";
import classNames from "classnames";
import Link from "next/link";
import Avatar from "@components/Avatar/Avatar";
import { RatingTargetReviewItem, RatingTargetType } from "@core/Models/Ratings.model";
import { ratingTargetReviews } from "@services/Ratings.Service";
@@ -58,9 +59,9 @@ export default function ReviewsCarousel({
let active = true;
ratingTargetReviews({ targetType, targetId, limit })
.then((targetReviews) => {
.then((targetReviewsResult) => {
if (active) {
setReviews(sortReviewsByScore(targetReviews.filter((review) => review.comment.trim().length > 0)));
setReviews(sortReviewsByScore(targetReviewsResult.reviews.filter((review) => review.comment.trim().length > 0)));
}
})
.catch(() => {
@@ -99,7 +100,12 @@ export default function ReviewsCarousel({
return (
<section className={classNames(style.reviewsCarousel, className)} aria-label="Opiniones">
<div className={style.header}>
<h2>{title}</h2>
<div className={style.titleRow}>
<h2>{title}</h2>
<Link className={style.viewMore} href={`/landing/reviews?targetType=${targetType}&targetId=${targetId}`}>
Ver más
</Link>
</div>
{subtitle && <p>{subtitle}</p>}
</div>
<div