From 115c03207725373c14ef83c4ad691e62636fe213 Mon Sep 17 00:00:00 2001 From: FABIO ARIAS Date: Sun, 9 Jun 2024 10:38:49 -0500 Subject: [PATCH 1/2] feat: Add new components and services for Kanban board and positions - Added new components for Kanban board: - `CandidateCard.tsx` - `KanbanBoard.tsx` - `KanbanColumn.tsx` - `Position.tsx` - `PositionHeader.tsx` - Updated existing components: - `Positions.tsx` to include mock positions and filters - Added new services for handling positions: - `positionService.js` with methods to fetch, add, update, and delete positions - Updated backend models and routes: - `Position.ts` model to include new fields and methods - `positionRoutes.ts` to include routes for getting candidates and interview flow by position - Updated API specifications: - `api-spec.yaml` to include endpoints for file upload, getting candidates by position, and updating candidate stage - Added new workspace and documentation files: - `lti.code-workspace` - `prompts.md` These changes enhance the functionality of the application by introducing a Kanban board for managing positions and improving the backend to support these features. --- backend/package.json | 2 + frontend/src/App.css | 28 +++ frontend/src/App.js | 4 +- frontend/src/components/CandidateCard.tsx | 14 ++ frontend/src/components/KanbanBoard.tsx | 17 ++ frontend/src/components/KanbanColumn.tsx | 16 ++ frontend/src/components/Position.tsx | 28 +++ frontend/src/components/PositionHeader.tsx | 16 ++ frontend/src/components/Positions.tsx | 23 +- frontend/src/services/candidateService.js | 35 ++- frontend/src/services/positionService.js | 37 +++ frontend/src/types/index.tsx | 16 ++ lti.code-workspace | 17 ++ prompts.md | 267 +++++++++++++++++++++ 14 files changed, 505 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/CandidateCard.tsx create mode 100644 frontend/src/components/KanbanBoard.tsx create mode 100644 frontend/src/components/KanbanColumn.tsx create mode 100644 frontend/src/components/Position.tsx create mode 100644 frontend/src/components/PositionHeader.tsx create mode 100644 frontend/src/services/positionService.js create mode 100644 frontend/src/types/index.tsx create mode 100644 lti.code-workspace create mode 100644 prompts.md diff --git a/backend/package.json b/backend/package.json index 9e93f2d..10688bf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,6 +9,8 @@ "build": "tsc", "test": "jest", "prisma:init": "npx prisma init", + "prisma:seed": "ts-node --transpile-only prisma/seed.ts", + "prisma:reset": "npx prisma migrate reset", "prisma:generate": "npx prisma generate", "start:prod": "npm run build && npm start" }, diff --git a/frontend/src/App.css b/frontend/src/App.css index 74b5e05..d520e7f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -36,3 +36,31 @@ transform: rotate(360deg); } } + +.kanban-board { + display: flex; + overflow-x: auto; + padding: 20px; + gap: 20px; +} + +.kanban-column { + flex: 0 0 300px; + background-color: #f4f4f4; + border-radius: 8px; + padding: 10px; + height: 90vh; + overflow-y: auto; +} + +.kanban-column:hover { + background-color: #e2e2e2; +} + +.candidate-card { + background-color: white; + border: 1px solid #ccc; + border-radius: 5px; + padding: 10px; + margin-bottom: 10px; +} \ No newline at end of file diff --git a/frontend/src/App.js b/frontend/src/App.js index 3f27809..75fbf64 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -4,14 +4,16 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import RecruiterDashboard from './components/RecruiterDashboard'; import AddCandidate from './components/AddCandidateForm'; import Positions from './components/Positions'; +import Position from './components/Position'; const App = () => { return ( } /> - } /> {/* Agrega esta línea */} + } /> } /> + } /> ); diff --git a/frontend/src/components/CandidateCard.tsx b/frontend/src/components/CandidateCard.tsx new file mode 100644 index 0000000..323d672 --- /dev/null +++ b/frontend/src/components/CandidateCard.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Candidate } from '../types/index'; +import '../App.css'; + +const CandidateCard: React.FC<{ candidate: Candidate }> = ({ candidate }) => { + return ( +
+

{candidate.firstName} {candidate.lastName}

+

Score: {candidate.averageScore}

+
+ ); +}; + +export default CandidateCard; diff --git a/frontend/src/components/KanbanBoard.tsx b/frontend/src/components/KanbanBoard.tsx new file mode 100644 index 0000000..a9600b5 --- /dev/null +++ b/frontend/src/components/KanbanBoard.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import KanbanColumn from './KanbanColumn'; +import { Candidate } from '../types/index'; +import '../App.css'; + +const KanbanBoard: React.FC<{ phases: string[]; candidates: Candidate[] }> = ({ phases, candidates }) => { + return ( +
+ {phases.map(phase => ( + c.phase === phase)} /> + ))} +
+ ); +}; + +export default KanbanBoard; + diff --git a/frontend/src/components/KanbanColumn.tsx b/frontend/src/components/KanbanColumn.tsx new file mode 100644 index 0000000..4b14de6 --- /dev/null +++ b/frontend/src/components/KanbanColumn.tsx @@ -0,0 +1,16 @@ +// src/components/KanbanColumn.tsx +import React from 'react'; +import CandidateCard from './CandidateCard'; +import '../App.css'; +import { Candidate } from '../types'; + +const KanbanColumn: React.FC<{ phase: string; candidates: Candidate[] }> = ({ phase, candidates }) => { + return ( +
+

{phase}

+ {candidates.map(candidate => )} +
+ ); +}; + +export default KanbanColumn; diff --git a/frontend/src/components/Position.tsx b/frontend/src/components/Position.tsx new file mode 100644 index 0000000..ad3d5da --- /dev/null +++ b/frontend/src/components/Position.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { useLocation } from 'react-router-dom'; +import PositionHeader from './PositionHeader'; +import KanbanBoard from './KanbanBoard'; + +const Position: React.FC = () => { + const location = useLocation(); + const { position } = location.state; + + // Mock data for candidates + const candidates = [ + { id: 1, firstName: "Jane", lastName: "Smith", email: "jane.smith@example.com", phase: "Technical Interview", averageScore: 4 }, + { id: 2, firstName: "Carlos", lastName: "García", email: "carlos.garcia@example.com", phase: "Initial Screening", averageScore: 0 }, + { id: 3, firstName: "John", lastName: "Doe", email: "john.doe@example.com", phase: "Manager Interview", averageScore: 5 } + ]; + + // Phases extracted from candidates data + const phases = Array.from(new Set(candidates.map(candidate => candidate.phase))); + + return ( +
+ + +
+ ); +}; + +export default Position; diff --git a/frontend/src/components/PositionHeader.tsx b/frontend/src/components/PositionHeader.tsx new file mode 100644 index 0000000..51c492c --- /dev/null +++ b/frontend/src/components/PositionHeader.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ArrowLeft } from 'react-bootstrap-icons'; + +const PositionHeader: React.FC<{ title: string }> = ({ title }) => { + const navigate = useNavigate(); + + return ( +
+ navigate(-1)} /> +

{title}

+
+ ); +}; + +export default PositionHeader; diff --git a/frontend/src/components/Positions.tsx b/frontend/src/components/Positions.tsx index 822dccd..8667b21 100644 --- a/frontend/src/components/Positions.tsx +++ b/frontend/src/components/Positions.tsx @@ -1,20 +1,21 @@ import React from 'react'; import { Card, Container, Row, Col, Form, Button } from 'react-bootstrap'; - -type Position = { - title: string; - manager: string; - deadline: string; - status: 'Abierto' | 'Contratado' | 'Cerrado' | 'Borrador'; -}; +import { useNavigate } from 'react-router-dom'; +import { Position } from '../types'; const mockPositions: Position[] = [ - { title: 'Senior Backend Engineer', manager: 'John Doe', deadline: '2024-12-31', status: 'Abierto' }, - { title: 'Junior Android Engineer', manager: 'Jane Smith', deadline: '2024-11-15', status: 'Contratado' }, - { title: 'Product Manager', manager: 'Alex Jones', deadline: '2024-07-31', status: 'Borrador' } + { id: 1, title: 'Senior Backend Engineer', manager: 'John Doe', deadline: '2024-12-31', status: 'Abierto' }, + { id: 2, title: 'Junior Android Engineer', manager: 'Jane Smith', deadline: '2024-11-15', status: 'Contratado' }, + { id: 3, title: 'Product Manager', manager: 'Alex Jones', deadline: '2024-07-31', status: 'Borrador' } ]; const Positions: React.FC = () => { + const navigate = useNavigate(); + + const handleViewProcess = (position: Position) => { + navigate('/position', { state: { position } }); + }; + return (

Posiciones

@@ -57,7 +58,7 @@ const Positions: React.FC = () => { {position.status}
- +
diff --git a/frontend/src/services/candidateService.js b/frontend/src/services/candidateService.js index dcaed98..74252cc 100644 --- a/frontend/src/services/candidateService.js +++ b/frontend/src/services/candidateService.js @@ -1,11 +1,14 @@ import axios from 'axios'; +// Set base url to axios +axios.defaults.baseURL = 'http://localhost:3010'; + export const uploadCV = async (file) => { const formData = new FormData(); formData.append('file', file); try { - const response = await axios.post('http://localhost:3010/upload', formData, { + const response = await axios.post('/upload', formData, { headers: { 'Content-Type': 'multipart/form-data', }, @@ -18,9 +21,35 @@ export const uploadCV = async (file) => { export const sendCandidateData = async (candidateData) => { try { - const response = await axios.post('http://localhost:3010/candidates', candidateData); + const response = await axios.post('/candidates', candidateData); return response.data; } catch (error) { throw new Error('Error al enviar datos del candidato:', error.response.data); } -}; \ No newline at end of file +}; + +export const fetchCandidates = async (positionId) => { + try { + const response = await axios.get(`/position/${positionId}/candidates`); + return response.data; + } catch (error) { + throw new Error('Error al obtener los candidatos:', error.response.data); + } +}; + +export const updateCandidateStage = async (candidateId, stageData) => { + try { + const response = await axios.patch(`/candidates/${candidateId}/stage`, stageData); + return response.data; + } catch (error) { + throw new Error('Error al actualizar la etapa del candidato:', error.response.data); + } +}; + +axios.interceptors.response.use( + response => response, + error => { + const message = error.response?.data?.message || 'Error desconocido'; + throw new Error(message); + } +); \ No newline at end of file diff --git a/frontend/src/services/positionService.js b/frontend/src/services/positionService.js new file mode 100644 index 0000000..10331b8 --- /dev/null +++ b/frontend/src/services/positionService.js @@ -0,0 +1,37 @@ +import axios from 'axios'; + +export const fetchAllPositions = async () => { + try { + const response = await axios.get('/positions'); + return response.data; + } catch (error) { + throw new Error('Error fetching positions:', error.response.data); + } +}; + +export const addPosition = async (positionData) => { + try { + const response = await axios.post('/positions', positionData); + return response.data; + } catch (error) { + throw new Error('Error adding position:', error.response.data); + } +}; + +export const updatePosition = async (positionId, positionData) => { + try { + const response = await axios.patch(`/positions/${positionId}`, positionData); + return response.data; + } catch (error) { + throw new Error('Error updating position:', error.response.data); + } +}; + +export const deletePosition = async (positionId) => { + try { + const response = await axios.delete(`/positions/${positionId}`); + return response.data; + } catch (error) { + throw new Error('Error deleting position:', error.response.data); + } +}; diff --git a/frontend/src/types/index.tsx b/frontend/src/types/index.tsx new file mode 100644 index 0000000..3a1febb --- /dev/null +++ b/frontend/src/types/index.tsx @@ -0,0 +1,16 @@ +export interface Candidate { + id: number; + firstName: string; + lastName: string; + email: string; + phase: string; + averageScore: number; +} + +export interface Position { + id: number; + title: string; + manager: string; + deadline: string; + status: 'Abierto' | 'Contratado' | 'Cerrado' | 'Borrador'; +} diff --git a/lti.code-workspace b/lti.code-workspace new file mode 100644 index 0000000..0d307d7 --- /dev/null +++ b/lti.code-workspace @@ -0,0 +1,17 @@ +{ + "folders": [ + { + "name": "Frontend", + "path": "./frontend" + }, + { + "name": "Backend", + "path": "./backend" + }, + { + "name": "ROOT", + "path": "." + } + ] +} + diff --git a/prompts.md b/prompts.md new file mode 100644 index 0000000..7ca1427 --- /dev/null +++ b/prompts.md @@ -0,0 +1,267 @@ +# Inicio + +```` +Basado en @Frontend necesitamos realizar el siguiente requerimiento: + +Crear la interfaz "position", una página en la que poder visualizar y gestionar los diferentes candidatos de una posición específica. + +Se ha decidido que la interfaz sea tipo kanban, mostrando los candidatos como tarjetas en diferentes columnas que representan las fases del proceso de contratación, y pudiendo actualizar la fase en la que se encuentra un candidato solo arrastrando su tarjeta. La interfaz sería similar a la imagen Adjunta. + +Los requerimientos funcionales son: + +* Se debe mostrar el título de la posición en la parte superior, para dar contexto +* Añadir una flecha a la izquierda del título que permita volver al listado de posiciones +* Deben mostrarse tantas columnas como fases haya en el proceso +* La tarjeta de cada candidato/a debe situarse en la fase correspondiente, y debe mostrar su nombre completo y su puntuación media +* Si es posible, debe mostrarse adecuadamente en móvil (las fases en vertical ocupando todo el ancho) + +No generes codigo aun solo define las tareas que se van a realizar y lo que se requiere para iniciar. Formula las preguntas necesarias. + +```` + +# Task Definition and Requirements Gathering for Kanban-style Interface + +To create the "position" interface as described, the following tasks need to be defined and preliminary questions need to be addressed: + +#### Tasks: +1. **Design the Interface:** + - Sketch or wireframe the layout of the Kanban board with columns representing different hiring phases. + - Design responsive views to ensure mobile compatibility. + +2. **Setup Routing:** + - Implement routing to navigate from the position listing to the specific position's Kanban board. + +3. **Develop Components:** + - **PositionHeader:** Displays the position title and a back arrow to return to the position listing. + - **KanbanBoard:** Manages the columns and drag-and-drop functionality. + - **KanbanColumn:** Represents each phase in the hiring process. + - **CandidateCard:** Displays candidate's full name and average score. + +4. **State Management:** + - Define the state structure to manage the list of candidates and their respective phases. + - Implement state management using Context API or Redux to handle updates when candidates are moved between phases. + +5. **API Integration:** + - Define API endpoints to fetch candidates and update their phases. + - Implement API calls within the application. + +6. **Testing:** + - Write unit and integration tests for each component and functionality. + +#### Preliminary Questions: +1. **Data Structure:** + - What are the different phases in the hiring process? How are they represented in the data? + - What details are available for each candidate (e.g., ID, name, score)? + +2. **API Details:** + - Are there existing API endpoints for fetching and updating candidate data, or do they need to be created? + - What are the authentication and authorization requirements for these APIs? + +3. **Mobile Compatibility:** + - Are there specific design requirements or limitations for the mobile view? + +4. **Performance Considerations:** + - How many candidates are typically managed per position? This could impact the choice of state management and rendering optimizations. + +5. **Accessibility:** + - Are there any specific accessibility requirements, such as keyboard navigation or screen reader support, for the interface? + +Addressing these tasks and questions will provide a solid foundation to start the development of the Kanban-style interface for managing candidates in different hiring phases. + +# Iteración 2 + +```` +USando principios SOLID y el @ManifestoBuenasPracticas.md iniciemos a crear los componentes necesarios. + +```` + +### Creating Components for the Kanban-style Interface Using SOLID Principles + +To start building the Kanban-style interface for managing candidates in different hiring phases, we'll apply SOLID principles as outlined in the provided best practices manifesto. Here's a breakdown of the components we need to create, along with the application of SOLID principles: + +#### 1. **PositionHeader Component** + - **Responsibility:** Display the position title and a back navigation arrow. + - **SOLID Principle Applied:** Single Responsibility Principle (SRP) - This component has only one reason to change, which is the modification of the header presentation. + +```typescript +// src/components/PositionHeader.tsx +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { ArrowLeft } from 'react-bootstrap-icons'; + +const PositionHeader: React.FC<{ title: string }> = ({ title }) => { + const history = useHistory(); + + return ( +
+ history.goBack()} /> +

{title}

+
+ ); +}; + +export default PositionHeader; +``` + +#### 2. **KanbanBoard Component** + - **Responsibility:** Manage the overall layout of the Kanban board, including the columns for different phases. + - **SOLID Principle Applied:** Open/Closed Principle (OCP) - This component is open for extension (e.g., adding more phases) but closed for modification. + +```typescript +// src/components/KanbanBoard.tsx +import React from 'react'; +import { KanbanColumn } from './KanbanColumn'; + +const KanbanBoard: React.FC<{ phases: string[]; candidates: Candidate[] }> = ({ phases, candidates }) => { + return ( +
+ {phases.map(phase => ( + c.phase === phase)} /> + ))} +
+ ); +}; + +export default KanbanBoard; +``` + +#### 3. **KanbanColumn Component** + - **Responsibility:** Display candidates in a specific phase. + - **SOLID Principle Applied:** Liskov Substitution Principle (LSP) - This component can be replaced with another version without affecting the behavior of the system. + +```typescript +// src/components/KanbanColumn.tsx +import React from 'react'; +import { CandidateCard } from './CandidateCard'; + +const KanbanColumn: React.FC<{ phase: string; candidates: Candidate[] }> = ({ phase, candidates }) => { + return ( +
+

{phase}

+ {candidates.map(candidate => )} +
+ ); +}; + +export default KanbanColumn; +``` + +#### 4. **CandidateCard Component** + - **Responsibility:** Display candidate's full name and average score. + - **SOLID Principle Applied:** Interface Segregation Principle (ISP) - Clients (other components) will not be forced to depend on interfaces they do not use. + +```typescript +// src/components/CandidateCard.tsx +import React from 'react'; + +const CandidateCard: React.FC<{ candidate: Candidate }> = ({ candidate }) => { + return ( +
+

{candidate.firstName} {candidate.lastName}

+

Score: {candidate.averageScore}

+
+ ); +}; + +export default CandidateCard; +``` + +#### 5. **API Integration and State Management** + - **Responsibility:** Fetch and update candidate data. + - **SOLID Principle Applied:** Dependency Inversion Principle (DIP) - High-level modules should not depend on low-level modules but should depend on abstractions. + +```typescript +// src/services/CandidateService.ts +import axios from 'axios'; + +export class CandidateService { + static async fetchCandidates(positionId: string): Promise { + return axios.get(`/api/positions/${positionId}/candidates`).then(res => res.data); + } + + static async updateCandidatePhase(candidateId: string, newPhase: string): Promise { + return axios.patch(`/api/candidates/${candidateId}`, { phase: newPhase }); + } +} +``` + +By adhering to SOLID principles and following the outlined tasks, we can ensure that the components are well-structured, maintainable, and scalable. + +# Iteración 3 + +```` +Crea el Componente Position que usaría los componentes antes creados, y has un mock de datos basado en el sigiente JSON + +```` + +````json + +[ + { + "fullName": "Jane Smith", + "currentInterviewStep": "Technical Interview", + "averageScore": 4 + }, + { + "fullName": "Carlos García", + "currentInterviewStep": "Initial Screening", + "averageScore": 0 + }, + { + "fullName": "John Doe", + "currentInterviewStep": "Manager Interview", + "averageScore": 5 + } +] +```` + +### Creating the [Position] Component with Mock Data + +To create the [Position] component that utilizes the previously defined components ([PositionHeader], [KanbanBoard], etc.), we'll first set up the component structure and then integrate mock data based on the provided JSON. + +#### Position Component +The [Position] component will serve as the main container for the Kanban board interface. It will use the [PositionHeader] to display the title and navigation, and the [KanbanBoard] to manage the display of candidates across different phases. + +```typescript +// src/components/Position.tsx +import React from 'react'; +import PositionHeader from './PositionHeader'; +import KanbanBoard from './KanbanBoard'; + +const Position: React.FC = () => { + // Mock data for candidates + const candidates = [ + { id: 1, fullName: "Jane Smith", phase: "Technical Interview", averageScore: 4 }, + { id: 2, fullName: "Carlos García", phase: "Initial Screening", averageScore: 0 }, + { id: 3, fullName: "John Doe", phase: "Manager Interview", averageScore: 5 } + ]; + + // Phases extracted from candidates data + const phases = Array.from(new Set(candidates.map(candidate => candidate.phase))); + + return ( +
+ + +
+ ); +}; + +export default Position; +``` + +#### Explanation +- **PositionHeader:** Displays the title and a back navigation arrow. +- **KanbanBoard:** Manages the display of candidates in columns corresponding to their current interview phase. +- **Mock Data:** The candidates' data is structured to fit the expected props for the `KanbanBoard` and `CandidateCard` components, with each candidate having an `id`, `fullName`, `phase`, and `averageScore`. + +This setup allows the `Position` component to function as a complete page for managing the recruitment process for a specific position using a Kanban-style interface. + + +# Iteracion 4 + +```` +@Positions.tsx usando los principios SOLID y @ManifestoBuenasPracticas.md al dar click en el boton de Ver Proceso este debe enviar la posición al componente @Position.tsx +```` + + From 5cd6b89c8c372f2219f53ff635ddb942deb344d1 Mon Sep 17 00:00:00 2001 From: FABIO ARIAS Date: Mon, 10 Jun 2024 07:00:21 -0500 Subject: [PATCH 2/2] Update global styles for improved layout consistency Add RatingCircle component to display rating score as a circle Refactor CandidateCard for enhanced readability and performance --- frontend/src/App.css | 26 ++++++++++++++++++++++- frontend/src/components/CandidateCard.tsx | 2 ++ frontend/src/components/RatingCircle.tsx | 13 ++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/RatingCircle.tsx diff --git a/frontend/src/App.css b/frontend/src/App.css index d520e7f..64ffb8b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -63,4 +63,28 @@ border-radius: 5px; padding: 10px; margin-bottom: 10px; -} \ No newline at end of file +} + +.rating-circle { + width: 30px; + height: 30px; + border-radius: 50%; + display: inline-block; + color: white; + line-height: 30px; + text-align: center; + font-weight: bold; + margin: 5px; +} + +[data-value='0'] { background-color: #ccc; } +[data-value='1'] { background-color: #f44336; } +[data-value='2'] { background-color: #e91e63; } +[data-value='3'] { background-color: #9c27b0; } +[data-value='4'] { background-color: #673ab7; } +[data-value='5'] { background-color: #3f51b5; } +[data-value='6'] { background-color: #2196f3; } +[data-value='7'] { background-color: #03a9f4; } +[data-value='8'] { background-color: #00bcd4; } +[data-value='9'] { background-color: #009688; } +[data-value='10'] { background-color: #4caf50; } \ No newline at end of file diff --git a/frontend/src/components/CandidateCard.tsx b/frontend/src/components/CandidateCard.tsx index 323d672..7f8706e 100644 --- a/frontend/src/components/CandidateCard.tsx +++ b/frontend/src/components/CandidateCard.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Candidate } from '../types/index'; +import RatingCircle from './RatingCircle'; import '../App.css'; const CandidateCard: React.FC<{ candidate: Candidate }> = ({ candidate }) => { @@ -7,6 +8,7 @@ const CandidateCard: React.FC<{ candidate: Candidate }> = ({ candidate }) => {

{candidate.firstName} {candidate.lastName}

Score: {candidate.averageScore}

+
); }; diff --git a/frontend/src/components/RatingCircle.tsx b/frontend/src/components/RatingCircle.tsx new file mode 100644 index 0000000..e7d833a --- /dev/null +++ b/frontend/src/components/RatingCircle.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +const RatingCircle = ({ score }: { score: number }) => { + return ( +
+ {Array.from({ length: score }, (_, index) => ( +
+ ))} +
+ ); +}; + +export default RatingCircle;