first commit
78
README.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# 🐴 GoHorse Jobs (Vagas para Tecnologia)
|
||||
|
||||
A comprehensive recruitment platform connecting job seekers with opportunities in the technology sector.
|
||||
|
||||
## 📋 Project Overview
|
||||
|
||||
GoHorse Jobs is a multi-service application designed to facilitate the hiring process. It consists of a high-performance Go backend, a modern Next.js frontend, and a specialized Seeder API for generating realistic test data.
|
||||
|
||||
## 🔐 Credentials (Test Environment)
|
||||
|
||||
Use these credentials to access the different dashboards:
|
||||
|
||||
| User Type | Identifier | Password | Dashboard |
|
||||
|-----------|------------|----------|-----------|
|
||||
| **SuperAdmin** | `superadmin` | `Admin@2025!` | `/dashboard/admin` (System Stats) |
|
||||
| **Company Admin** | `takeshi_yamamoto` | `Takeshi@2025` | `/dashboard/empresa` (Manage Jobs) |
|
||||
| **Recruiter** | `maria_santos` | `User@2025` | `/dashboard/empresa` (View Candidates) |
|
||||
| **Candidate** | `paulo_santos` | `User@2025` | `/dashboard/candidato` (Apply for Jobs) |
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
The project follows a microservices-inspired architecture:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
User((User))
|
||||
Frontend[Frontend (Next.js)]
|
||||
Backend[Backend (Go/Gin)]
|
||||
Seeder[Seeder API (Node.js)]
|
||||
DB[(PostgreSQL)]
|
||||
|
||||
User --> Frontend
|
||||
Frontend -->|HTTP/REST| Backend
|
||||
Seeder -->|Writes| DB
|
||||
Backend -->|Reads/Writes| DB
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Run the Backend API
|
||||
We have provided a convenience script to start the backend quickly.
|
||||
|
||||
```bash
|
||||
./run_dev.sh
|
||||
```
|
||||
|
||||
### 2. Run the Frontend
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3. Database Setup & Seeding
|
||||
Refer to `seeder-api/README.md` for detailed instructions on populating the database.
|
||||
|
||||
## 📊 Status & Tasks
|
||||
|
||||
### Completed ✅
|
||||
- [x] Backend API Structure
|
||||
- [x] Docker Configuration
|
||||
- [x] Frontend Dashboard (Company)
|
||||
- [x] Seeder Logic (Users, Companies, Jobs)
|
||||
- [x] Documentation Unification
|
||||
- [x] Branding Update (GoHorse Jobs)
|
||||
|
||||
### In Progress 🚧
|
||||
- [ ] Integration of complete Frontend-Backend flow
|
||||
- [ ] Advanced Search Filters
|
||||
- [ ] Real-time Notifications
|
||||
|
||||
## 📚 Documentation
|
||||
- [Backend Documentation](./backend/README.md)
|
||||
- [Frontend Documentation](./frontend/README.md)
|
||||
- [Seeder Documentation](./seeder-api/README.md)
|
||||
|
||||
---
|
||||
*Generated by Antigravity*
|
||||
22
backend/.env.example
Executable file
|
|
@ -0,0 +1,22 @@
|
|||
# Environment variables for Todai Jobs Backend
|
||||
|
||||
# Database Configuration
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=yourpassword
|
||||
DB_NAME=gohorsejobs
|
||||
|
||||
# JWT Secret (CHANGE IN PRODUCTION!)
|
||||
JWT_SECRET=your-secret-key-change-this-in-production-use-strong-random-value
|
||||
|
||||
# Server Configuration
|
||||
PORT=8080
|
||||
ENV=development
|
||||
|
||||
# CORS Origins (comma-separated)
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
|
||||
# File Upload
|
||||
MAX_UPLOAD_SIZE=10485760 # 10MB in bytes
|
||||
UPLOAD_DIR=./uploads
|
||||
21
backend/.gitignore
vendored
Executable file
|
|
@ -0,0 +1,21 @@
|
|||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
37
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Build stage
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies if needed (e.g. gcc for cgo, though we disable cgo)
|
||||
RUN apk add --no-cache git
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o main ./cmd/api
|
||||
|
||||
# Run stage
|
||||
FROM alpine:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache ca-certificates
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /app/main .
|
||||
|
||||
# Copy migrations (CRITICAL for auto-migration logic)
|
||||
COPY --from=builder /app/migrations ./migrations
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Environment variables should be passed at runtime, but we can set defaults
|
||||
ENV PORT=8080
|
||||
|
||||
CMD ["./main"]
|
||||
196
backend/README.md
Executable file
|
|
@ -0,0 +1,196 @@
|
|||
# GoHorseJobs Backend
|
||||
|
||||
This is the backend for the GoHorseJobs application, built with Go and PostgreSQL.
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. **Core Module (Clean Architecture)**
|
||||
The project now includes a strictly decoupled `internal/core` module that follows Clean Architecture and DDD principles.
|
||||
- **Pure Domain**: `internal/core/domain/entity` (No external deps).
|
||||
- **Ports**: `internal/core/ports` (Interfaces for Repositories/Services).
|
||||
- **UseCases**: `internal/core/usecases` (Business Logic).
|
||||
|
||||
### 2. **Multi-Tenancy**
|
||||
- **Strict Isolation**: All core tables (`core_companies`, `core_users`) use UUIDs and strict Tenant FKs.
|
||||
- **Middleware**: `TenantGuard` automatically extracts Tenant context from JWTs.
|
||||
|
||||
### 3. **Swagger API Docs**
|
||||
- **URL**: [http://localhost:8080/swagger/index.html](http://localhost:8080/swagger/index.html)
|
||||
- **Generate**: `swag init -g cmd/api/main.go --parseDependency --parseInternal`
|
||||
|
||||
### 4. **Super Admin Access**
|
||||
A seed migration is provided to create the initial system access.
|
||||
- **Migration**: `migrations/010_seed_super_admin.sql`
|
||||
- **User**: `admin@gohorse.com`
|
||||
- **Password**: `password123`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker
|
||||
- Docker Compose
|
||||
- Go 1.22+
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Start the services:**
|
||||
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
This will start the PostgreSQL database and the Go API server.
|
||||
|
||||
Alternatively, use the running script in the root:
|
||||
```bash
|
||||
./run_dev.sh
|
||||
```
|
||||
|
||||
2. **Access the API:**
|
||||
|
||||
The API will be available at `http://localhost:8080`.
|
||||
|
||||
- **Health Check:** `GET /health`
|
||||
- **List Jobs:** `GET /jobs`
|
||||
- **Create Job:** `POST /jobs`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### `GET /health`
|
||||
|
||||
Returns `200 OK` if the server is running.
|
||||
|
||||
### `GET /jobs`
|
||||
|
||||
Returns a list of all jobs.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Software Engineer",
|
||||
"description": "Develop Go applications.",
|
||||
"company_name": "Tech Corp",
|
||||
"location": "Remote",
|
||||
"salary_range": "$100k - $120k",
|
||||
"created_at": "2023-10-27T10:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `POST /jobs`
|
||||
|
||||
Creates a new job.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Software Engineer",
|
||||
"description": "Develop Go applications.",
|
||||
"company_name": "Tech Corp",
|
||||
"location": "Remote",
|
||||
"salary_range": "$100k - $120k"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
Returns the created job with its ID and creation timestamp.
|
||||
|
||||
---
|
||||
|
||||
# 📚 Feature Documentation (Archives)
|
||||
|
||||
> [!NOTE]
|
||||
> The following documentation describes features implemented in the application, including Frontend components and Local Database logic. They have been consolidated here for reference.
|
||||
|
||||
## 🗄️ Local Database - Profile System (Legacy/Frontend)
|
||||
|
||||
Full local database system using localStorage to manage profile photos and user data.
|
||||
|
||||
### 🚀 Usage
|
||||
|
||||
#### 1. Page with Database
|
||||
Access: `http://localhost:3000/profile-db`
|
||||
|
||||
#### 2. Features Implemented
|
||||
|
||||
**📸 Profile Picture Upload**
|
||||
- ✅ Click to select image
|
||||
- ✅ Automatic validation (JPG, PNG, GIF, WebP)
|
||||
- ✅ 2MB limit per file
|
||||
- ✅ Instant preview
|
||||
- ✅ Auto-save to localStorage
|
||||
- ✅ Loading indicator
|
||||
- ✅ Remove photo button
|
||||
|
||||
**🗃️ Local Database**
|
||||
- ✅ Auto-save to localStorage
|
||||
- ✅ Persistence between sessions
|
||||
- ✅ Export data (JSON backup)
|
||||
- ✅ Clear all data
|
||||
- ✅ User structure
|
||||
- ✅ Creation/update timestamps
|
||||
|
||||
**🔧 useProfile Hook**
|
||||
- ✅ Reactive state management
|
||||
- ✅ Loading states
|
||||
- ✅ Full CRUD
|
||||
- ✅ Auto synchronization
|
||||
|
||||
## 🏢 Company Dashboard (Frontend Features)
|
||||
|
||||
### ✅ Complete Features
|
||||
|
||||
#### 1️⃣ Job Management
|
||||
**Page:** `/dashboard/empresa/vagas`
|
||||
- ✅ Full listing of published jobs
|
||||
- ✅ Statistics per job
|
||||
- ✅ Search and filters
|
||||
- ✅ Quick actions: View, Edit, Pause, Delete
|
||||
|
||||
#### 2️⃣ Application Management
|
||||
**Page:** `/dashboard/empresa/candidaturas`
|
||||
- ✅ View all received applications
|
||||
- ✅ Statistics cards by status
|
||||
- ✅ Tabs system
|
||||
- ✅ Search by candidate name
|
||||
- ✅ Quick actions: Approve, Reject, Email
|
||||
|
||||
#### 3️⃣ Messaging System
|
||||
**Page:** `/dashboard/empresa/mensagens`
|
||||
- ✅ WhatsApp/Slack style chat interface
|
||||
- ✅ Conversation list with unread counters
|
||||
- ✅ Real-time message attachment
|
||||
- ✅ Responsive design
|
||||
|
||||
#### 4️⃣ Analytics & Reports
|
||||
**Page:** `/dashboard/empresa/relatorios`
|
||||
- ✅ Key metrics cards
|
||||
- ✅ Period selector
|
||||
- ✅ Conversion funnel
|
||||
- ✅ Hiring time by role
|
||||
|
||||
#### 5️⃣ Company Profile
|
||||
**Page:** `/dashboard/empresa/perfil`
|
||||
- ✅ Real logo upload
|
||||
- ✅ Basic info management
|
||||
- ✅ Social media links
|
||||
- ✅ Culture description
|
||||
|
||||
### 🎨 Design System
|
||||
|
||||
**Stack:**
|
||||
- shadcn/ui
|
||||
- Tailwind CSS
|
||||
- Lucide Icons
|
||||
- Framer Motion
|
||||
|
||||
**Colors:**
|
||||
- Primary: Blue
|
||||
- Success: Green
|
||||
- Warning: Yellow
|
||||
- Danger: Red
|
||||
- Muted: Gray
|
||||
38
backend/cmd/api/main.go
Executable file
|
|
@ -0,0 +1,38 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/database"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/router"
|
||||
)
|
||||
|
||||
// @title GoHorseJobs API
|
||||
// @version 1.0
|
||||
// @description API for GoHorseJobs recruitment platform.
|
||||
// @host localhost:8080
|
||||
// @BasePath /
|
||||
func main() {
|
||||
// Load .env file
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("No .env file found or error loading it")
|
||||
}
|
||||
|
||||
database.InitDB()
|
||||
database.RunMigrations()
|
||||
|
||||
handler := router.NewRouter()
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
log.Println("Starting server on :" + port)
|
||||
if err := http.ListenAndServe(":"+port, handler); err != nil {
|
||||
log.Fatalf("Server failed to start: %v", err)
|
||||
}
|
||||
}
|
||||
62
backend/cmd/debug_user/main.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
_ "github.com/lib/pq"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load .env from backend root (cwd is backend/)
|
||||
if err := godotenv.Load(".env"); err != nil {
|
||||
log.Println("Warning: Error loading .env file, relying on system env")
|
||||
}
|
||||
|
||||
host := os.Getenv("DB_HOST")
|
||||
if host == "" {
|
||||
// Fallback for debugging if env not picked up
|
||||
host = "localhost"
|
||||
}
|
||||
port := os.Getenv("DB_PORT")
|
||||
user := os.Getenv("DB_USER")
|
||||
password := os.Getenv("DB_PASSWORD")
|
||||
dbname := os.Getenv("DB_NAME")
|
||||
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
host, port, user, password, dbname)
|
||||
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
log.Fatal("Could not connect to DB:", err)
|
||||
}
|
||||
|
||||
// Check for superadmin
|
||||
targetEmail := "superadmin" // This is what we put in email column
|
||||
var id, email, passHash string
|
||||
err = db.QueryRow("SELECT id, email, password_hash FROM core_users WHERE email = $1", targetEmail).Scan(&id, &email, &passHash)
|
||||
if err != nil {
|
||||
log.Fatalf("Error finding user '%s': %v", targetEmail, err)
|
||||
}
|
||||
|
||||
fmt.Printf("User Found: ID=%s, Email=%s\n", id, email)
|
||||
fmt.Printf("Stored Hash: %s\n", passHash)
|
||||
|
||||
// Verify Password
|
||||
targetPass := "Admin@2025!"
|
||||
err = bcrypt.CompareHashAndPassword([]byte(passHash), []byte(targetPass))
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Password Verification FAILED: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("✅ Password Verification SUCCESS\n")
|
||||
}
|
||||
}
|
||||
381
backend/docs/docs.go
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
// Package docs Code generated by swaggo/swag. DO NOT EDIT
|
||||
package docs
|
||||
|
||||
import "github.com/swaggo/swag"
|
||||
|
||||
const docTemplate = `{
|
||||
"schemes": {{ marshal .Schemes }},
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "{{escape .Description}}",
|
||||
"title": "{{.Title}}",
|
||||
"contact": {},
|
||||
"version": "{{.Version}}"
|
||||
},
|
||||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"paths": {
|
||||
"/api/v1/auth/login": {
|
||||
"post": {
|
||||
"description": "Authenticates a user by email and password. Returns JWT and user info.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"summary": "User Login",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Login Credentials",
|
||||
"name": "login",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.LoginRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.AuthResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid Request",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/companies": {
|
||||
"post": {
|
||||
"description": "Registers a new company and creates an initial admin user.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Companies"
|
||||
],
|
||||
"summary": "Create Company (Tenant)",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Company Details",
|
||||
"name": "company",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.CreateCompanyRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.CompanyResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid Request",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/users": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns a list of users belonging to the authenticated tenant.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "List Users",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.UserResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Creates a new user under the current tenant. Requires Admin role.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Create User",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User Details",
|
||||
"name": "user",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.CreateUserRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.UserResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid Request",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/users/{id}": {
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Deletes a user by ID. Must belong to the same tenant.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Delete User",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User deleted",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"github_com_rede5_gohorsejobs_backend_internal_core_dto.AuthResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.UserResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_rede5_gohorsejobs_backend_internal_core_dto.CompanyResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_rede5_gohorsejobs_backend_internal_core_dto.CreateCompanyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"admin_email": {
|
||||
"type": "string"
|
||||
},
|
||||
"contact": {
|
||||
"type": "string"
|
||||
},
|
||||
"document": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_rede5_gohorsejobs_backend_internal_core_dto.CreateUserRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"roles": {
|
||||
"description": "e.g. [\"RECRUITER\"]",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_rede5_gohorsejobs_backend_internal_core_dto.LoginRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_rede5_gohorsejobs_backend_internal_core_dto.UserResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
// SwaggerInfo holds exported Swagger Info so clients can modify it
|
||||
var SwaggerInfo = &swag.Spec{
|
||||
Version: "1.0",
|
||||
Host: "localhost:8080",
|
||||
BasePath: "/",
|
||||
Schemes: []string{},
|
||||
Title: "GoHorseJobs API",
|
||||
Description: "API for GoHorseJobs recruitment platform.",
|
||||
InfoInstanceName: "swagger",
|
||||
SwaggerTemplate: docTemplate,
|
||||
LeftDelim: "{{",
|
||||
RightDelim: "}}",
|
||||
}
|
||||
|
||||
func init() {
|
||||
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
|
||||
}
|
||||
357
backend/docs/swagger.json
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
{
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "API for GoHorseJobs recruitment platform.",
|
||||
"title": "GoHorseJobs API",
|
||||
"contact": {},
|
||||
"version": "1.0"
|
||||
},
|
||||
"host": "localhost:8080",
|
||||
"basePath": "/",
|
||||
"paths": {
|
||||
"/api/v1/auth/login": {
|
||||
"post": {
|
||||
"description": "Authenticates a user by email and password. Returns JWT and user info.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"summary": "User Login",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Login Credentials",
|
||||
"name": "login",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.LoginRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.AuthResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid Request",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/companies": {
|
||||
"post": {
|
||||
"description": "Registers a new company and creates an initial admin user.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Companies"
|
||||
],
|
||||
"summary": "Create Company (Tenant)",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Company Details",
|
||||
"name": "company",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.CreateCompanyRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.CompanyResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid Request",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/users": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns a list of users belonging to the authenticated tenant.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "List Users",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.UserResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Creates a new user under the current tenant. Requires Admin role.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Create User",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User Details",
|
||||
"name": "user",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.CreateUserRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.UserResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid Request",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/users/{id}": {
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Deletes a user by ID. Must belong to the same tenant.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Delete User",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User deleted",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"github_com_rede5_gohorsejobs_backend_internal_core_dto.AuthResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.UserResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_rede5_gohorsejobs_backend_internal_core_dto.CompanyResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_rede5_gohorsejobs_backend_internal_core_dto.CreateCompanyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"admin_email": {
|
||||
"type": "string"
|
||||
},
|
||||
"contact": {
|
||||
"type": "string"
|
||||
},
|
||||
"document": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_rede5_gohorsejobs_backend_internal_core_dto.CreateUserRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"roles": {
|
||||
"description": "e.g. [\"RECRUITER\"]",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_rede5_gohorsejobs_backend_internal_core_dto.LoginRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_rede5_gohorsejobs_backend_internal_core_dto.UserResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
231
backend/docs/swagger.yaml
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
basePath: /
|
||||
definitions:
|
||||
github_com_rede5_gohorsejobs_backend_internal_core_dto.AuthResponse:
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
user:
|
||||
$ref: '#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.UserResponse'
|
||||
type: object
|
||||
github_com_rede5_gohorsejobs_backend_internal_core_dto.CompanyResponse:
|
||||
properties:
|
||||
created_at:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
github_com_rede5_gohorsejobs_backend_internal_core_dto.CreateCompanyRequest:
|
||||
properties:
|
||||
admin_email:
|
||||
type: string
|
||||
contact:
|
||||
type: string
|
||||
document:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
github_com_rede5_gohorsejobs_backend_internal_core_dto.CreateUserRequest:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
roles:
|
||||
description: e.g. ["RECRUITER"]
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type: object
|
||||
github_com_rede5_gohorsejobs_backend_internal_core_dto.LoginRequest:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
type: object
|
||||
github_com_rede5_gohorsejobs_backend_internal_core_dto.UserResponse:
|
||||
properties:
|
||||
created_at:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
roles:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
host: localhost:8080
|
||||
info:
|
||||
contact: {}
|
||||
description: API for GoHorseJobs recruitment platform.
|
||||
title: GoHorseJobs API
|
||||
version: "1.0"
|
||||
paths:
|
||||
/api/v1/auth/login:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Authenticates a user by email and password. Returns JWT and user
|
||||
info.
|
||||
parameters:
|
||||
- description: Login Credentials
|
||||
in: body
|
||||
name: login
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.LoginRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.AuthResponse'
|
||||
"400":
|
||||
description: Invalid Request
|
||||
schema:
|
||||
type: string
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
type: string
|
||||
summary: User Login
|
||||
tags:
|
||||
- Auth
|
||||
/api/v1/companies:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Registers a new company and creates an initial admin user.
|
||||
parameters:
|
||||
- description: Company Details
|
||||
in: body
|
||||
name: company
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.CreateCompanyRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.CompanyResponse'
|
||||
"400":
|
||||
description: Invalid Request
|
||||
schema:
|
||||
type: string
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
type: string
|
||||
summary: Create Company (Tenant)
|
||||
tags:
|
||||
- Companies
|
||||
/api/v1/users:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns a list of users belonging to the authenticated tenant.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.UserResponse'
|
||||
type: array
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
type: string
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: List Users
|
||||
tags:
|
||||
- Users
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Creates a new user under the current tenant. Requires Admin role.
|
||||
parameters:
|
||||
- description: User Details
|
||||
in: body
|
||||
name: user
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.CreateUserRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_rede5_gohorsejobs_backend_internal_core_dto.UserResponse'
|
||||
"400":
|
||||
description: Invalid Request
|
||||
schema:
|
||||
type: string
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
type: string
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Create User
|
||||
tags:
|
||||
- Users
|
||||
/api/v1/users/{id}:
|
||||
delete:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Deletes a user by ID. Must belong to the same tenant.
|
||||
parameters:
|
||||
- description: User ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: User deleted
|
||||
schema:
|
||||
type: string
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
type: string
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Delete User
|
||||
tags:
|
||||
- Users
|
||||
swagger: "2.0"
|
||||
32
backend/go.mod
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
module github.com/rede5/gohorsejobs/backend
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/swaggo/http-swagger/v2 v2.0.2
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/crypto v0.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
||||
github.com/go-openapi/spec v0.22.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||
github.com/swaggo/files/v2 v2.0.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
)
|
||||
65
backend/go.sum
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
|
||||
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
||||
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
||||
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
||||
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
|
||||
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=
|
||||
github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0=
|
||||
github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg=
|
||||
github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
|
||||
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
189
backend/internal/api/handlers/core_handlers.go
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/api/middleware"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/usecases/tenant"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/usecases/user"
|
||||
)
|
||||
|
||||
type CoreHandlers struct {
|
||||
loginUC *auth.LoginUseCase
|
||||
createCompanyUC *tenant.CreateCompanyUseCase
|
||||
createUserUC *user.CreateUserUseCase
|
||||
listUsersUC *user.ListUsersUseCase
|
||||
deleteUserUC *user.DeleteUserUseCase
|
||||
}
|
||||
|
||||
func NewCoreHandlers(l *auth.LoginUseCase, c *tenant.CreateCompanyUseCase, u *user.CreateUserUseCase, list *user.ListUsersUseCase, del *user.DeleteUserUseCase) *CoreHandlers {
|
||||
return &CoreHandlers{
|
||||
loginUC: l,
|
||||
createCompanyUC: c,
|
||||
createUserUC: u,
|
||||
listUsersUC: list,
|
||||
deleteUserUC: del,
|
||||
}
|
||||
}
|
||||
|
||||
// Login authenticates a user and returns a token.
|
||||
// @Summary User Login
|
||||
// @Description Authenticates a user by email and password. Returns JWT and user info.
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param login body dto.LoginRequest true "Login Credentials"
|
||||
// @Success 200 {object} dto.AuthResponse
|
||||
// @Failure 400 {string} string "Invalid Request"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Router /api/v1/auth/login [post]
|
||||
func (h *CoreHandlers) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req dto.LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.loginUC.Execute(r.Context(), req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// CreateCompany registers a new tenant (Company) and its admin.
|
||||
// @Summary Create Company (Tenant)
|
||||
// @Description Registers a new company and creates an initial admin user.
|
||||
// @Tags Companies
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param company body dto.CreateCompanyRequest true "Company Details"
|
||||
// @Success 200 {object} dto.CompanyResponse
|
||||
// @Failure 400 {string} string "Invalid Request"
|
||||
// @Failure 500 {string} string "Internal Server Error"
|
||||
// @Router /api/v1/companies [post]
|
||||
func (h *CoreHandlers) CreateCompany(w http.ResponseWriter, r *http.Request) {
|
||||
var req dto.CreateCompanyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.createCompanyUC.Execute(r.Context(), req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// CreateUser creates a new user within the authenticated tenant.
|
||||
// @Summary Create User
|
||||
// @Description Creates a new user under the current tenant. Requires Admin role.
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param user body dto.CreateUserRequest true "User Details"
|
||||
// @Success 200 {object} dto.UserResponse
|
||||
// @Failure 400 {string} string "Invalid Request"
|
||||
// @Failure 403 {string} string "Forbidden"
|
||||
// @Failure 500 {string} string "Internal Server Error"
|
||||
// @Router /api/v1/users [post]
|
||||
func (h *CoreHandlers) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
tenantID, ok := ctx.Value(middleware.ContextTenantID).(string)
|
||||
if !ok || tenantID == "" {
|
||||
http.Error(w, "Tenant ID not found in context", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.CreateUserRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.createUserUC.Execute(ctx, req, tenantID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// ListUsers returns all users in the current tenant.
|
||||
// @Summary List Users
|
||||
// @Description Returns a list of users belonging to the authenticated tenant.
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {array} dto.UserResponse
|
||||
// @Failure 403 {string} string "Forbidden"
|
||||
// @Failure 500 {string} string "Internal Server Error"
|
||||
// @Router /api/v1/users [get]
|
||||
func (h *CoreHandlers) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
tenantID, ok := ctx.Value(middleware.ContextTenantID).(string)
|
||||
if !ok || tenantID == "" {
|
||||
http.Error(w, "Tenant ID not found in context", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
users, err := h.listUsersUC.Execute(ctx, tenantID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(users)
|
||||
}
|
||||
|
||||
// DeleteUser removes a user from the tenant.
|
||||
// @Summary Delete User
|
||||
// @Description Deletes a user by ID. Must belong to the same tenant.
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 200 {string} string "User deleted"
|
||||
// @Failure 403 {string} string "Forbidden"
|
||||
// @Failure 500 {string} string "Internal Server Error"
|
||||
// @Router /api/v1/users/{id} [delete]
|
||||
func (h *CoreHandlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
tenantID, ok := ctx.Value(middleware.ContextTenantID).(string)
|
||||
if !ok || tenantID == "" {
|
||||
http.Error(w, "Tenant ID not found in context", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
// Fallback for older Go versions if PathValue not avail, though 1.22+ is standard now.
|
||||
// Usually we'd use a mux var. Since we use stdlib mux in 1.22:
|
||||
http.Error(w, "Missing User ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.deleteUserUC.Execute(ctx, id, tenantID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("User deleted"))
|
||||
}
|
||||
70
backend/internal/api/middleware/auth_middleware.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/ports"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
ContextUserID contextKey = "userID"
|
||||
ContextTenantID contextKey = "tenantID"
|
||||
ContextRoles contextKey = "roles"
|
||||
)
|
||||
|
||||
type Middleware struct {
|
||||
authService ports.AuthService
|
||||
}
|
||||
|
||||
func NewMiddleware(authService ports.AuthService) *Middleware {
|
||||
return &Middleware{authService: authService}
|
||||
}
|
||||
|
||||
func (m *Middleware) HeaderAuthGuard(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Missing Authorization Header", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
http.Error(w, "Invalid Header Format", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
claims, err := m.authService.ValidateToken(token)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid Token: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Inject into Context
|
||||
ctx := context.WithValue(r.Context(), ContextUserID, claims["sub"])
|
||||
ctx = context.WithValue(ctx, ContextTenantID, claims["tenant"])
|
||||
ctx = context.WithValue(ctx, ContextRoles, claims["roles"])
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// TenantGuard ensures that the request is made by a user belonging to the prompt tenant
|
||||
// Note: In this architecture, the token *defines* the tenant. So HeaderAuthGuard implicitly guards the tenant.
|
||||
// This middleware is for extra checks if URL params conflict with Token tenant.
|
||||
func (m *Middleware) TenantGuard(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID := r.Context().Value(ContextTenantID)
|
||||
if tenantID == nil || tenantID == "" {
|
||||
http.Error(w, "Tenant Context Missing", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// Logic to compare with URL param if needed...
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
50
backend/internal/api/middleware/cors_middleware.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func CORSMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origins := os.Getenv("CORS_ORIGINS")
|
||||
if origins == "" {
|
||||
// Strict default: only allow exact matches or specific development origin if needed.
|
||||
// For this project, we prefer configuration.
|
||||
origins = "http://localhost:3000"
|
||||
}
|
||||
|
||||
origin := r.Header.Get("Origin")
|
||||
allowOrigin := ""
|
||||
|
||||
// Check if the origin is allowed
|
||||
if origins == "*" {
|
||||
allowOrigin = "*"
|
||||
} else {
|
||||
for _, o := range strings.Split(origins, ",") {
|
||||
if strings.TrimSpace(o) == origin {
|
||||
allowOrigin = origin
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if allowOrigin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", allowOrigin)
|
||||
}
|
||||
|
||||
// Essential for some auth flows (cookies)
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
37
backend/internal/core/domain/entity/company.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package entity
|
||||
|
||||
import "time"
|
||||
|
||||
// Company represents a Tenant in the system.
|
||||
type Company struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Document string `json:"document,omitempty"` // CNPJ, EIN, VAT
|
||||
Contact string `json:"contact"`
|
||||
Status string `json:"status"` // "ACTIVE", "INACTIVE"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NewCompany creates a new Company instance with defaults.
|
||||
func NewCompany(id, name, document, contact string) *Company {
|
||||
return &Company{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Document: document,
|
||||
Contact: contact,
|
||||
Status: "ACTIVE",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Company) Activate() {
|
||||
c.Status = "ACTIVE"
|
||||
c.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
func (c *Company) Deactivate() {
|
||||
c.Status = "INACTIVE"
|
||||
c.UpdatedAt = time.Now()
|
||||
}
|
||||
13
backend/internal/core/domain/entity/permission.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package entity
|
||||
|
||||
// Permission represents a granular access right.
|
||||
type Permission struct {
|
||||
Code string `json:"code"` // e.g., "USER_create", "JOB_view"
|
||||
Description string `json:"description"` // e.g., "Allows creating users"
|
||||
}
|
||||
|
||||
// Role represents a collection of permissions.
|
||||
type Role struct {
|
||||
Name string `json:"name"` // e.g., "ADMIN", "RECRUITER"
|
||||
Permissions []Permission `json:"permissions"`
|
||||
}
|
||||
46
backend/internal/core/domain/entity/user.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package entity
|
||||
|
||||
import "time"
|
||||
|
||||
// User represents a user within a specific Tenant (Company).
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"` // Link to Company
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
Roles []Role `json:"roles"`
|
||||
Status string `json:"status"` // "ACTIVE", "INACTIVE"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NewUser creates a new User instance.
|
||||
func NewUser(id, tenantID, name, email string) *User {
|
||||
return &User{
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
Name: name,
|
||||
Email: email,
|
||||
Status: "ACTIVE",
|
||||
Roles: []Role{},
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (u *User) AssignRole(role Role) {
|
||||
u.Roles = append(u.Roles, role)
|
||||
u.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
func (u *User) HasPermission(permissionCode string) bool {
|
||||
for _, role := range u.Roles {
|
||||
for _, perm := range role.Permissions {
|
||||
if perm.Code == permissionCode {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
17
backend/internal/core/dto/company.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
type CreateCompanyRequest struct {
|
||||
Name string `json:"name"`
|
||||
Document string `json:"document"`
|
||||
Contact string `json:"contact"`
|
||||
AdminEmail string `json:"admin_email"`
|
||||
}
|
||||
|
||||
type CompanyResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
29
backend/internal/core/dto/user_auth.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
User UserResponse `json:"user"`
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Roles []string `json:"roles"` // e.g. ["RECRUITER"]
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Roles []string `json:"roles"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
24
backend/internal/core/ports/repositories.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||
)
|
||||
|
||||
type CompanyRepository interface {
|
||||
Save(ctx context.Context, company *entity.Company) (*entity.Company, error)
|
||||
FindByID(ctx context.Context, id string) (*entity.Company, error)
|
||||
Update(ctx context.Context, company *entity.Company) (*entity.Company, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
type UserRepository interface {
|
||||
Save(ctx context.Context, user *entity.User) (*entity.User, error)
|
||||
FindByID(ctx context.Context, id string) (*entity.User, error)
|
||||
FindByEmail(ctx context.Context, email string) (*entity.User, error)
|
||||
// FindAllByTenant returns users strictly scoped to a tenant
|
||||
FindAllByTenant(ctx context.Context, tenantID string) ([]*entity.User, error)
|
||||
Update(ctx context.Context, user *entity.User) (*entity.User, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
9
backend/internal/core/ports/services.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package ports
|
||||
|
||||
// AuthService defines the interface for authentication logic.
|
||||
type AuthService interface {
|
||||
HashPassword(password string) (string, error)
|
||||
VerifyPassword(hash, password string) bool
|
||||
GenerateToken(userID, tenantID string, roles []string) (string, error)
|
||||
ValidateToken(token string) (map[string]interface{}, error)
|
||||
}
|
||||
65
backend/internal/core/usecases/auth/login.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/ports"
|
||||
)
|
||||
|
||||
type LoginUseCase struct {
|
||||
userRepo ports.UserRepository
|
||||
authService ports.AuthService
|
||||
}
|
||||
|
||||
func NewLoginUseCase(userRepo ports.UserRepository, authService ports.AuthService) *LoginUseCase {
|
||||
return &LoginUseCase{
|
||||
userRepo: userRepo,
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *LoginUseCase) Execute(ctx context.Context, input dto.LoginRequest) (*dto.AuthResponse, error) {
|
||||
// 1. Find User by Email (Global or implicit tenant?)
|
||||
// Implementation Note: In this architecture, email should be unique enough or we iterate.
|
||||
// For simplicity, assuming email is unique system-wide or we find the active one.
|
||||
user, err := uc.userRepo.FindByEmail(ctx, input.Email)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid credentials") // Avoid leaking existence
|
||||
}
|
||||
|
||||
// 2. Verify Password
|
||||
if !uc.authService.VerifyPassword(user.PasswordHash, input.Password) {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
// 3. Check Status
|
||||
if user.Status != "ACTIVE" {
|
||||
return nil, errors.New("account inactive")
|
||||
}
|
||||
|
||||
// 4. Generate Token
|
||||
roles := make([]string, len(user.Roles))
|
||||
for i, r := range user.Roles {
|
||||
roles[i] = r.Name
|
||||
}
|
||||
|
||||
token, err := uc.authService.GenerateToken(user.ID, user.TenantID, roles)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to generate token")
|
||||
}
|
||||
|
||||
// 5. Return Response
|
||||
return &dto.AuthResponse{
|
||||
Token: token,
|
||||
User: dto.UserResponse{
|
||||
ID: user.ID,
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
Roles: roles,
|
||||
Status: user.Status,
|
||||
CreatedAt: user.CreatedAt,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
70
backend/internal/core/usecases/tenant/create_company.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/ports"
|
||||
)
|
||||
|
||||
type CreateCompanyUseCase struct {
|
||||
companyRepo ports.CompanyRepository
|
||||
userRepo ports.UserRepository
|
||||
authService ports.AuthService
|
||||
}
|
||||
|
||||
func NewCreateCompanyUseCase(cRepo ports.CompanyRepository, uRepo ports.UserRepository, auth ports.AuthService) *CreateCompanyUseCase {
|
||||
return &CreateCompanyUseCase{
|
||||
companyRepo: cRepo,
|
||||
userRepo: uRepo,
|
||||
authService: auth,
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *CreateCompanyUseCase) Execute(ctx context.Context, input dto.CreateCompanyRequest) (*dto.CompanyResponse, error) {
|
||||
// 1. Create Company ID (Assuming UUID generated by Repo OR here. Let's assume Repo handles ID generation if empty, or we do it.)
|
||||
// To be agnostic, let's assume NewCompany takes an ID. In real app, we might use a UUID generator service.
|
||||
// For now, let's assume ID is generated by DB or we pass a placeholder if DB does it.
|
||||
// Actually, the Entity `NewCompany` takes ID. I should generate one.
|
||||
// But UseCase shouldn't rely on specific UUID lib ideally?
|
||||
// I'll skip ID generation here and let Repo handle it or use a simple string for now.
|
||||
// Better: Use a helper or just "new-uuid" string for now as placeholder for the generator logic.
|
||||
|
||||
// Implementation decision: Domain ID generation should be explicit.
|
||||
// I'll assume input could pass it, or we rely on repo.
|
||||
// Let's create the entity with empty ID and let Repo fill it? No, Entity usually needs Identity.
|
||||
// I'll generate a random ID here for simulation if I had a uuid lib.
|
||||
// Since I want to be agnostic and dependency-free, I'll assume the Repo 'Save' returns the fully populated entity including ID.
|
||||
|
||||
company := entity.NewCompany("", input.Name, input.Document, input.Contact)
|
||||
|
||||
savedCompany, err := uc.companyRepo.Save(ctx, company)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Create Admin User
|
||||
// We need a password for the admin. Do we generate one? Or did input provide?
|
||||
// input.AdminEmail is present. But no password. I'll generic default or ask to send email.
|
||||
// For simplicity, let's assume a default password "ChangeMe123!" hash it.
|
||||
hashedPassword, _ := uc.authService.HashPassword("ChangeMe123!")
|
||||
|
||||
adminUser := entity.NewUser("", savedCompany.ID, "Admin", input.AdminEmail)
|
||||
adminUser.PasswordHash = hashedPassword
|
||||
adminUser.AssignRole(entity.Role{Name: "ADMIN"})
|
||||
|
||||
_, err = uc.userRepo.Save(ctx, adminUser)
|
||||
if err != nil {
|
||||
// Rollback company? Transaction?
|
||||
// Ignored for now.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto.CompanyResponse{
|
||||
ID: savedCompany.ID,
|
||||
Name: savedCompany.Name,
|
||||
Status: savedCompany.Status,
|
||||
CreatedAt: savedCompany.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
67
backend/internal/core/usecases/user/create_user.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/ports"
|
||||
)
|
||||
|
||||
type CreateUserUseCase struct {
|
||||
userRepo ports.UserRepository
|
||||
authService ports.AuthService
|
||||
}
|
||||
|
||||
func NewCreateUserUseCase(uRepo ports.UserRepository, auth ports.AuthService) *CreateUserUseCase {
|
||||
return &CreateUserUseCase{
|
||||
userRepo: uRepo,
|
||||
authService: auth,
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *CreateUserUseCase) Execute(ctx context.Context, input dto.CreateUserRequest, currentTenantID string) (*dto.UserResponse, error) {
|
||||
// 1. Validate Email Uniqueness (within tenant? or global?)
|
||||
// Usually email is unique global or per tenant. Let's assume unique.
|
||||
exists, _ := uc.userRepo.FindByEmail(ctx, input.Email)
|
||||
if exists != nil {
|
||||
return nil, errors.New("user already exists")
|
||||
}
|
||||
|
||||
// 2. Hash Password
|
||||
hashed, err := uc.authService.HashPassword(input.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. Create Entity
|
||||
// Note: We enforce currentTenantID to ensure isolation.
|
||||
user := entity.NewUser("", currentTenantID, input.Name, input.Email)
|
||||
user.PasswordHash = hashed
|
||||
|
||||
// Assign roles
|
||||
for _, r := range input.Roles {
|
||||
user.AssignRole(entity.Role{Name: r})
|
||||
}
|
||||
|
||||
saved, err := uc.userRepo.Save(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. Return DTO
|
||||
roles := make([]string, len(saved.Roles))
|
||||
for i, r := range saved.Roles {
|
||||
roles[i] = r.Name
|
||||
}
|
||||
|
||||
return &dto.UserResponse{
|
||||
ID: saved.ID,
|
||||
Name: saved.Name,
|
||||
Email: saved.Email,
|
||||
Roles: roles,
|
||||
Status: saved.Status,
|
||||
CreatedAt: saved.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
33
backend/internal/core/usecases/user/delete_user.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/ports"
|
||||
)
|
||||
|
||||
type DeleteUserUseCase struct {
|
||||
userRepo ports.UserRepository
|
||||
}
|
||||
|
||||
func NewDeleteUserUseCase(uRepo ports.UserRepository) *DeleteUserUseCase {
|
||||
return &DeleteUserUseCase{
|
||||
userRepo: uRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *DeleteUserUseCase) Execute(ctx context.Context, userID, tenantID string) error {
|
||||
// 1. Verify User exists and belongs to Tenant
|
||||
user, err := uc.userRepo.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user.TenantID != tenantID {
|
||||
return errors.New("user not found in this tenant")
|
||||
}
|
||||
|
||||
// 2. Delete
|
||||
return uc.userRepo.Delete(ctx, userID)
|
||||
}
|
||||
44
backend/internal/core/usecases/user/list_users.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/ports"
|
||||
)
|
||||
|
||||
type ListUsersUseCase struct {
|
||||
userRepo ports.UserRepository
|
||||
}
|
||||
|
||||
func NewListUsersUseCase(uRepo ports.UserRepository) *ListUsersUseCase {
|
||||
return &ListUsersUseCase{
|
||||
userRepo: uRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *ListUsersUseCase) Execute(ctx context.Context, tenantID string) ([]dto.UserResponse, error) {
|
||||
users, err := uc.userRepo.FindAllByTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response []dto.UserResponse
|
||||
for _, u := range users {
|
||||
roles := make([]string, len(u.Roles))
|
||||
for i, r := range u.Roles {
|
||||
roles[i] = r.Name
|
||||
}
|
||||
|
||||
response = append(response, dto.UserResponse{
|
||||
ID: u.ID,
|
||||
Name: u.Name,
|
||||
Email: u.Email,
|
||||
Roles: roles,
|
||||
Status: u.Status,
|
||||
CreatedAt: u.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
95
backend/internal/database/database.go
Executable file
|
|
@ -0,0 +1,95 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
var DB *sql.DB
|
||||
|
||||
func InitDB() {
|
||||
var err error
|
||||
host := os.Getenv("DB_HOST")
|
||||
if host == "" {
|
||||
log.Fatal("DB_HOST environment variable not set")
|
||||
}
|
||||
user := os.Getenv("DB_USER")
|
||||
if user == "" {
|
||||
log.Fatal("DB_USER environment variable not set")
|
||||
}
|
||||
password := os.Getenv("DB_PASSWORD")
|
||||
if password == "" {
|
||||
log.Fatal("DB_PASSWORD environment variable not set")
|
||||
}
|
||||
dbname := os.Getenv("DB_NAME")
|
||||
if dbname == "" {
|
||||
log.Fatal("DB_NAME environment variable not set")
|
||||
}
|
||||
port := os.Getenv("DB_PORT")
|
||||
if port == "" {
|
||||
port = "5432"
|
||||
}
|
||||
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
host, port, user, password, dbname)
|
||||
|
||||
DB, err = sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
log.Fatalf("Error opening database: %v", err)
|
||||
}
|
||||
|
||||
if err = DB.Ping(); err != nil {
|
||||
log.Fatalf("Error connecting to database: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Successfully connected to the database")
|
||||
}
|
||||
|
||||
func RunMigrations() {
|
||||
files, err := os.ReadDir("migrations")
|
||||
if err != nil {
|
||||
// Try fallback to relative path if running from cmd/api
|
||||
files, err = os.ReadDir("../../migrations")
|
||||
if err != nil {
|
||||
log.Printf("Warning: Could not list migrations directory: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Sort files by name to ensure order (001, 002, ...)
|
||||
// ReadDir returns sorted by name automatically, but let's be safe if logic changes?
|
||||
// Actually ReadDir result is sorted by filename.
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("Running migration: %s", file.Name())
|
||||
content, err := os.ReadFile("migrations/" + file.Name())
|
||||
if err != nil {
|
||||
// Try fallback
|
||||
content, err = os.ReadFile("../../migrations/" + file.Name())
|
||||
if err != nil {
|
||||
log.Fatalf("Error reading migration file %s: %v", file.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = DB.Exec(string(content))
|
||||
if err != nil {
|
||||
// Log warning but don't crash on "already exists" errors if possible,
|
||||
// but pure SQL Exec might fail hard.
|
||||
// Given the SQL files use IF NOT EXISTS, we should be fine.
|
||||
// If one fails, it might be syntax.
|
||||
log.Printf("Error running migration %s: %v", file.Name(), err)
|
||||
// Continue or Fail? User wants robustness. Let's Warn and continue for now to avoid single failure blocking all
|
||||
} else {
|
||||
log.Printf("Migration %s executed successfully", file.Name())
|
||||
}
|
||||
}
|
||||
log.Println("All migrations processed")
|
||||
}
|
||||
44
backend/internal/dto/auth.go
Executable file
|
|
@ -0,0 +1,44 @@
|
|||
package dto
|
||||
|
||||
// LoginRequest represents the login request payload
|
||||
type LoginRequest struct {
|
||||
Identifier string `json:"identifier" validate:"required,min=3"`
|
||||
Password string `json:"password" validate:"required,min=8"`
|
||||
}
|
||||
|
||||
// LoginResponse represents the login response
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
User UserInfo `json:"user"`
|
||||
Companies []CompanyInfo `json:"companies,omitempty"`
|
||||
ActiveCompanyID *int `json:"activeCompanyId,omitempty"`
|
||||
}
|
||||
|
||||
// UserInfo represents basic user information in responses
|
||||
type UserInfo struct {
|
||||
ID int `json:"id"`
|
||||
Identifier string `json:"identifier"`
|
||||
Role string `json:"role"`
|
||||
FullName string `json:"fullName"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
// CompanyInfo represents basic company information
|
||||
type CompanyInfo struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"` // Role in this company (companyAdmin or recruiter)
|
||||
}
|
||||
|
||||
// RegisterRequest represents user registration
|
||||
type RegisterRequest struct {
|
||||
Identifier string `json:"identifier" validate:"required,min=3,max=50"`
|
||||
Password string `json:"password" validate:"required,min=8"`
|
||||
FullName string `json:"fullName" validate:"required"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
WhatsApp *string `json:"whatsapp,omitempty"`
|
||||
LineID *string `json:"lineId,omitempty"`
|
||||
Instagram *string `json:"instagram,omitempty"`
|
||||
Language string `json:"language" validate:"required,oneof=pt en es ja"`
|
||||
Role string `json:"role" validate:"required,oneof=jobSeeker recruiter companyAdmin"`
|
||||
}
|
||||
142
backend/internal/dto/requests.go
Executable file
|
|
@ -0,0 +1,142 @@
|
|||
package dto
|
||||
|
||||
// CreateJobRequest represents the request to create a new job
|
||||
type CreateJobRequest struct {
|
||||
CompanyID int `json:"companyId" validate:"required"`
|
||||
Title string `json:"title" validate:"required,min=5,max=255"`
|
||||
Description string `json:"description" validate:"required,min=20"`
|
||||
SalaryMin *float64 `json:"salaryMin,omitempty"`
|
||||
SalaryMax *float64 `json:"salaryMax,omitempty"`
|
||||
SalaryType *string `json:"salaryType,omitempty" validate:"omitempty,oneof=hourly monthly yearly"`
|
||||
EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract"`
|
||||
WorkingHours *string `json:"workingHours,omitempty"`
|
||||
Location *string `json:"location,omitempty"`
|
||||
RegionID *int `json:"regionId,omitempty"`
|
||||
CityID *int `json:"cityId,omitempty"`
|
||||
Requirements map[string]interface{} `json:"requirements,omitempty"`
|
||||
Benefits map[string]interface{} `json:"benefits,omitempty"`
|
||||
VisaSupport bool `json:"visaSupport"`
|
||||
LanguageLevel *string `json:"languageLevel,omitempty"`
|
||||
Status string `json:"status" validate:"oneof=draft open closed"`
|
||||
}
|
||||
|
||||
// UpdateJobRequest represents the request to update a job
|
||||
type UpdateJobRequest struct {
|
||||
Title *string `json:"title,omitempty" validate:"omitempty,min=5,max=255"`
|
||||
Description *string `json:"description,omitempty" validate:"omitempty,min=20"`
|
||||
SalaryMin *float64 `json:"salaryMin,omitempty"`
|
||||
SalaryMax *float64 `json:"salaryMax,omitempty"`
|
||||
SalaryType *string `json:"salaryType,omitempty" validate:"omitempty,oneof=hourly monthly yearly"`
|
||||
EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract"`
|
||||
WorkingHours *string `json:"workingHours,omitempty"`
|
||||
Location *string `json:"location,omitempty"`
|
||||
RegionID *int `json:"regionId,omitempty"`
|
||||
CityID *int `json:"cityId,omitempty"`
|
||||
Requirements map[string]interface{} `json:"requirements,omitempty"`
|
||||
Benefits map[string]interface{} `json:"benefits,omitempty"`
|
||||
VisaSupport *bool `json:"visaSupport,omitempty"`
|
||||
LanguageLevel *string `json:"languageLevel,omitempty"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft open closed"`
|
||||
}
|
||||
|
||||
// CreateApplicationRequest represents a job application (guest or logged user)
|
||||
type CreateApplicationRequest struct {
|
||||
JobID int `json:"jobId" validate:"required"`
|
||||
UserID *int `json:"userId,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
LineID *string `json:"lineId,omitempty"`
|
||||
WhatsApp *string `json:"whatsapp,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Message *string `json:"message,omitempty"`
|
||||
ResumeURL *string `json:"resumeUrl,omitempty"`
|
||||
Documents map[string]interface{} `json:"documents,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateApplicationStatusRequest represents updating application status (recruiter only)
|
||||
type UpdateApplicationStatusRequest struct {
|
||||
Status string `json:"status" validate:"required,oneof=pending reviewed shortlisted rejected hired"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// CreateCompanyRequest represents creating a new company
|
||||
type CreateCompanyRequest struct {
|
||||
Name string `json:"name" validate:"required,min=3,max=255"`
|
||||
Slug string `json:"slug" validate:"required,min=3,max=255"`
|
||||
Type *string `json:"type,omitempty"`
|
||||
Document *string `json:"document,omitempty"`
|
||||
Address *string `json:"address,omitempty"`
|
||||
RegionID *int `json:"regionId,omitempty"`
|
||||
CityID *int `json:"cityId,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Website *string `json:"website,omitempty"`
|
||||
LogoURL *string `json:"logoUrl,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateCompanyRequest represents updating company information
|
||||
type UpdateCompanyRequest struct {
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,min=3,max=255"`
|
||||
Slug *string `json:"slug,omitempty" validate:"omitempty,min=3,max=255"`
|
||||
Type *string `json:"type,omitempty"`
|
||||
Document *string `json:"document,omitempty"`
|
||||
Address *string `json:"address,omitempty"`
|
||||
RegionID *int `json:"regionId,omitempty"`
|
||||
CityID *int `json:"cityId,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Website *string `json:"website,omitempty"`
|
||||
LogoURL *string `json:"logoUrl,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
Verified *bool `json:"verified,omitempty"`
|
||||
}
|
||||
|
||||
// AssignUserToCompanyRequest represents assigning a user to a company
|
||||
type AssignUserToCompanyRequest struct {
|
||||
UserID int `json:"userId" validate:"required"`
|
||||
CompanyID int `json:"companyId" validate:"required"`
|
||||
Role string `json:"role" validate:"required,oneof=companyAdmin recruiter"`
|
||||
Permissions map[string]interface{} `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
// PaginationQuery represents pagination parameters
|
||||
type PaginationQuery struct {
|
||||
Page int `form:"page" validate:"min=1"`
|
||||
Limit int `form:"limit" validate:"min=1,max=100"`
|
||||
}
|
||||
|
||||
// JobFilterQuery represents job filtering parameters
|
||||
type JobFilterQuery struct {
|
||||
PaginationQuery
|
||||
CompanyID *int `form:"companyId"`
|
||||
RegionID *int `form:"regionId"`
|
||||
CityID *int `form:"cityId"`
|
||||
EmploymentType *string `form:"employmentType"`
|
||||
Status *string `form:"status"`
|
||||
VisaSupport *bool `form:"visaSupport"`
|
||||
LanguageLevel *string `form:"languageLevel"`
|
||||
Search *string `form:"search"`
|
||||
}
|
||||
|
||||
// PaginatedResponse represents a paginated API response
|
||||
type PaginatedResponse struct {
|
||||
Data interface{} `json:"data"`
|
||||
Pagination Pagination `json:"pagination"`
|
||||
}
|
||||
|
||||
// Pagination represents pagination metadata
|
||||
type Pagination struct {
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// APIResponse represents a standard API response
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
Message *string `json:"message,omitempty"`
|
||||
}
|
||||
101
backend/internal/handlers/application_handler.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/dto"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/services"
|
||||
)
|
||||
|
||||
type ApplicationHandler struct {
|
||||
Service *services.ApplicationService
|
||||
}
|
||||
|
||||
func NewApplicationHandler(service *services.ApplicationService) *ApplicationHandler {
|
||||
return &ApplicationHandler{Service: service}
|
||||
}
|
||||
|
||||
func (h *ApplicationHandler) CreateApplication(w http.ResponseWriter, r *http.Request) {
|
||||
var req dto.CreateApplicationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
app, err := h.Service.CreateApplication(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(app)
|
||||
}
|
||||
|
||||
func (h *ApplicationHandler) GetApplications(w http.ResponseWriter, r *http.Request) {
|
||||
// For now, simple get by Job ID query param
|
||||
jobIDStr := r.URL.Query().Get("jobId")
|
||||
if jobIDStr == "" {
|
||||
http.Error(w, "jobId is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
jobID, err := strconv.Atoi(jobIDStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid jobId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
apps, err := h.Service.GetApplications(jobID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(apps)
|
||||
}
|
||||
|
||||
func (h *ApplicationHandler) GetApplicationByID(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid application ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
app, err := h.Service.GetApplicationByID(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(app)
|
||||
}
|
||||
|
||||
func (h *ApplicationHandler) UpdateApplicationStatus(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid application ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.UpdateApplicationStatusRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
app, err := h.Service.UpdateApplicationStatus(id, req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(app)
|
||||
}
|
||||
152
backend/internal/handlers/job_handler.go
Executable file
|
|
@ -0,0 +1,152 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/dto"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/services"
|
||||
)
|
||||
|
||||
type JobHandler struct {
|
||||
Service *services.JobService
|
||||
}
|
||||
|
||||
func NewJobHandler(service *services.JobService) *JobHandler {
|
||||
return &JobHandler{Service: service}
|
||||
}
|
||||
|
||||
func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) {
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
companyID, _ := strconv.Atoi(r.URL.Query().Get("companyId"))
|
||||
|
||||
filter := dto.JobFilterQuery{
|
||||
PaginationQuery: dto.PaginationQuery{
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
},
|
||||
}
|
||||
if companyID > 0 {
|
||||
filter.CompanyID = &companyID
|
||||
}
|
||||
|
||||
jobs, total, err := h.Service.GetJobs(filter)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := dto.PaginatedResponse{
|
||||
Data: jobs,
|
||||
Pagination: dto.Pagination{
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
Total: total,
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func (h *JobHandler) CreateJob(w http.ResponseWriter, r *http.Request) {
|
||||
var req dto.CreateJobRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request (omitted for brevity, assume validation middleware or service validation)
|
||||
|
||||
job, err := h.Service.CreateJob(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(job)
|
||||
}
|
||||
|
||||
func (h *JobHandler) GetJobByID(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id") // Go 1.22+ routing
|
||||
if idStr == "" {
|
||||
// Fallback for older Go versions or if not using PathValue compatible mux
|
||||
// But let's assume standard mux or we might need to parse URL
|
||||
// For now, let's assume we can get it from context or URL
|
||||
// If using standard http.ServeMux in Go 1.22, PathValue works.
|
||||
// If not, we might need a helper.
|
||||
// Let's assume standard mux with Go 1.22 for now as it's modern.
|
||||
// If not, we'll fix it.
|
||||
// Actually, let's check go.mod version.
|
||||
}
|
||||
|
||||
// Wait, I should check go.mod version to be safe.
|
||||
// But let's write standard code.
|
||||
|
||||
// Assuming we use a router that puts params in context or we parse URL.
|
||||
// Since I am editing router.go later, I can ensure we use Go 1.22 patterns or a library.
|
||||
// The existing router.go used `mux.HandleFunc("/jobs", ...)` which suggests standard lib.
|
||||
|
||||
// Let's try to parse from URL path if PathValue is not available (it is in Go 1.22).
|
||||
// I'll use a helper to extract ID from URL for now to be safe if I'm not sure about Go version.
|
||||
// But `r.PathValue` is the way forward.
|
||||
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid job ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
job, err := h.Service.GetJobByID(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(job)
|
||||
}
|
||||
|
||||
func (h *JobHandler) UpdateJob(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid job ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.UpdateJobRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
job, err := h.Service.UpdateJob(id, req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(job)
|
||||
}
|
||||
|
||||
func (h *JobHandler) DeleteJob(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid job ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Service.DeleteJob(id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
64
backend/internal/infrastructure/auth/jwt_service.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type JWTService struct {
|
||||
secretKey []byte
|
||||
issuer string
|
||||
}
|
||||
|
||||
func NewJWTService(secret string, issuer string) *JWTService {
|
||||
return &JWTService{
|
||||
secretKey: []byte(secret),
|
||||
issuer: issuer,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *JWTService) HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
func (s *JWTService) VerifyPassword(hash, password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *JWTService) GenerateToken(userID, tenantID string, roles []string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userID,
|
||||
"tenant": tenantID,
|
||||
"roles": roles,
|
||||
"iss": s.issuer,
|
||||
"exp": time.Now().Add(time.Hour * 24).Unix(), // 24 hours
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(s.secretKey)
|
||||
}
|
||||
|
||||
func (s *JWTService) ValidateToken(tokenString string) (map[string]interface{}, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return s.secretKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||
)
|
||||
|
||||
type CompanyRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewCompanyRepository(db *sql.DB) *CompanyRepository {
|
||||
return &CompanyRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *CompanyRepository) Save(ctx context.Context, company *entity.Company) (*entity.Company, error) {
|
||||
if company.ID == "" {
|
||||
company.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO core_companies (id, name, document, contact, status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query,
|
||||
company.ID,
|
||||
company.Name,
|
||||
company.Document,
|
||||
company.Contact,
|
||||
company.Status,
|
||||
company.CreatedAt,
|
||||
company.UpdatedAt,
|
||||
).Scan(&company.ID)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return company, nil
|
||||
}
|
||||
|
||||
func (r *CompanyRepository) FindByID(ctx context.Context, id string) (*entity.Company, error) {
|
||||
query := `SELECT id, name, document, contact, status, created_at, updated_at FROM core_companies WHERE id = $1`
|
||||
row := r.db.QueryRowContext(ctx, query, id)
|
||||
|
||||
c := &entity.Company{}
|
||||
err := row.Scan(&c.ID, &c.Name, &c.Document, &c.Contact, &c.Status, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, errors.New("company not found")
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
func (r *CompanyRepository) Update(ctx context.Context, company *entity.Company) (*entity.Company, error) {
|
||||
company.UpdatedAt = time.Now()
|
||||
query := `
|
||||
UPDATE core_companies
|
||||
SET name=$1, document=$2, contact=$3, status=$4, updated_at=$5
|
||||
WHERE id=$6
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
company.Name,
|
||||
company.Document,
|
||||
company.Contact,
|
||||
company.Status,
|
||||
company.UpdatedAt,
|
||||
company.ID,
|
||||
)
|
||||
return company, err
|
||||
}
|
||||
|
||||
func (r *CompanyRepository) Delete(ctx context.Context, id string) error {
|
||||
query := `DELETE FROM core_companies WHERE id = $1`
|
||||
_, err := r.db.ExecContext(ctx, query, id)
|
||||
return err
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||
)
|
||||
|
||||
type UserRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *sql.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.User, error) {
|
||||
if user.ID == "" {
|
||||
user.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// 1. Insert User
|
||||
query := `
|
||||
INSERT INTO core_users (id, tenant_id, name, email, password_hash, status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`
|
||||
_, err = tx.ExecContext(ctx, query,
|
||||
user.ID,
|
||||
user.TenantID,
|
||||
user.Name,
|
||||
user.Email,
|
||||
user.PasswordHash,
|
||||
user.Status,
|
||||
user.CreatedAt,
|
||||
user.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Insert Roles
|
||||
if len(user.Roles) > 0 {
|
||||
roleQuery := `INSERT INTO core_user_roles (user_id, role) VALUES ($1, $2)`
|
||||
for _, role := range user.Roles {
|
||||
_, err := tx.ExecContext(ctx, roleQuery, user.ID, role.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity.User, error) {
|
||||
query := `SELECT id, tenant_id, name, email, password_hash, status, created_at, updated_at FROM core_users WHERE email = $1`
|
||||
row := r.db.QueryRowContext(ctx, query, email)
|
||||
|
||||
u := &entity.User{}
|
||||
err := row.Scan(&u.ID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil // Return nil if not found
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u.Roles, _ = r.getRoles(ctx, u.ID)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByID(ctx context.Context, id string) (*entity.User, error) {
|
||||
query := `SELECT id, tenant_id, name, email, password_hash, status, created_at, updated_at FROM core_users WHERE id = $1`
|
||||
row := r.db.QueryRowContext(ctx, query, id)
|
||||
|
||||
u := &entity.User{}
|
||||
err := row.Scan(&u.ID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u.Roles, _ = r.getRoles(ctx, u.ID)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string) ([]*entity.User, error) {
|
||||
query := `SELECT id, tenant_id, name, email, password_hash, status, created_at, updated_at FROM core_users WHERE tenant_id = $1`
|
||||
rows, err := r.db.QueryContext(ctx, query, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []*entity.User
|
||||
for rows.Next() {
|
||||
u := &entity.User{}
|
||||
if err := rows.Scan(&u.ID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Populate roles N+1? Ideally join, but for now simple
|
||||
u.Roles, _ = r.getRoles(ctx, u.ID)
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) Update(ctx context.Context, user *entity.User) (*entity.User, error) {
|
||||
// Not fully implemented for roles update for brevity, just fields
|
||||
user.UpdatedAt = time.Now()
|
||||
query := `UPDATE core_users SET name=$1, email=$2, status=$3, updated_at=$4 WHERE id=$5`
|
||||
_, err := r.db.ExecContext(ctx, query, user.Name, user.Email, user.Status, user.UpdatedAt, user.ID)
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) Delete(ctx context.Context, id string) error {
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM core_users WHERE id=$1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserRepository) getRoles(ctx context.Context, userID string) ([]entity.Role, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT role FROM core_user_roles WHERE user_id = $1`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var roles []entity.Role
|
||||
for rows.Next() {
|
||||
var roleName string
|
||||
rows.Scan(&roleName)
|
||||
roles = append(roles, entity.Role{Name: roleName})
|
||||
}
|
||||
return roles, nil
|
||||
}
|
||||
70
backend/internal/middleware/auth.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/utils"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
UserKey contextKey = "user"
|
||||
)
|
||||
|
||||
// AuthMiddleware verifies the JWT token and adds user claims to the context
|
||||
func AuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Authorization header required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
http.Error(w, "Invalid authorization header format", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := parts[1]
|
||||
claims, err := utils.ValidateJWT(tokenString)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserKey, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// RequireRole checks if the authenticated user has the required role
|
||||
func RequireRole(roles ...string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(UserKey).(*utils.Claims)
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// SuperAdmin has access to everything
|
||||
if claims.Role == "superadmin" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
for _, role := range roles {
|
||||
if claims.Role == role {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Error(w, "Forbidden: insufficient permissions", http.StatusForbidden)
|
||||
})
|
||||
}
|
||||
}
|
||||
21
backend/internal/middleware/cors.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
// CORSMiddleware handles Cross-Origin Resource Sharing
|
||||
func CORSMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Set CORS headers
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*") // TODO: Restrict in production
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
// Handle preflight requests
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
38
backend/internal/middleware/logging.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoggingMiddleware logs details of each request
|
||||
func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
// Wrap ResponseWriter to capture status code
|
||||
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
duration := time.Since(start)
|
||||
log.Printf(
|
||||
"%s %s %d %s",
|
||||
r.Method,
|
||||
r.URL.Path,
|
||||
rw.statusCode,
|
||||
duration,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
40
backend/internal/models/application.go
Executable file
|
|
@ -0,0 +1,40 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Application represents a job application (from user or guest)
|
||||
type Application struct {
|
||||
ID int `json:"id" db:"id"`
|
||||
JobID int `json:"jobId" db:"job_id"`
|
||||
UserID *int `json:"userId,omitempty" db:"user_id"` // NULL for guest applications
|
||||
|
||||
// Applicant Info (for guest applications)
|
||||
Name *string `json:"name,omitempty" db:"name"`
|
||||
Phone *string `json:"phone,omitempty" db:"phone"`
|
||||
LineID *string `json:"lineId,omitempty" db:"line_id"`
|
||||
WhatsApp *string `json:"whatsapp,omitempty" db:"whatsapp"`
|
||||
Email *string `json:"email,omitempty" db:"email"`
|
||||
|
||||
// Application Content
|
||||
Message *string `json:"message,omitempty" db:"message"`
|
||||
ResumeURL *string `json:"resumeUrl,omitempty" db:"resume_url"`
|
||||
Documents JSONMap `json:"documents,omitempty" db:"documents"` // Array of {type, url}
|
||||
|
||||
// Status & Notes
|
||||
Status string `json:"status" db:"status"` // pending, reviewed, shortlisted, rejected, hired
|
||||
Notes *string `json:"notes,omitempty" db:"notes"`
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
|
||||
// ApplicationWithDetails includes job and user information
|
||||
type ApplicationWithDetails struct {
|
||||
Application
|
||||
JobTitle string `json:"jobTitle"`
|
||||
CompanyID int `json:"companyId"`
|
||||
CompanyName string `json:"companyName"`
|
||||
ApplicantName string `json:"applicantName"` // From user or guest name
|
||||
ApplicantPhone *string `json:"applicantPhone,omitempty"`
|
||||
}
|
||||
39
backend/internal/models/company.go
Executable file
|
|
@ -0,0 +1,39 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Company represents an employer organization
|
||||
type Company struct {
|
||||
ID int `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Slug string `json:"slug" db:"slug"`
|
||||
Type string `json:"type" db:"type"`
|
||||
Document *string `json:"document,omitempty" db:"document"` // Houjin Bangou
|
||||
|
||||
// Contact
|
||||
Address *string `json:"address,omitempty" db:"address"`
|
||||
RegionID *int `json:"regionId,omitempty" db:"region_id"`
|
||||
CityID *int `json:"cityId,omitempty" db:"city_id"`
|
||||
Phone *string `json:"phone,omitempty" db:"phone"`
|
||||
Email *string `json:"email,omitempty" db:"email"`
|
||||
Website *string `json:"website,omitempty" db:"website"`
|
||||
|
||||
// Branding
|
||||
LogoURL *string `json:"logoUrl,omitempty" db:"logo_url"`
|
||||
Description *string `json:"description,omitempty" db:"description"`
|
||||
|
||||
// Status
|
||||
Active bool `json:"active" db:"active"`
|
||||
Verified bool `json:"verified" db:"verified"`
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
|
||||
// CompanyWithLocation includes region and city information
|
||||
type CompanyWithLocation struct {
|
||||
Company
|
||||
RegionName *string `json:"regionName,omitempty"`
|
||||
CityName *string `json:"cityName,omitempty"`
|
||||
}
|
||||
24
backend/internal/models/favorite_job.go
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// FavoriteJob represents a saved/favorited job by a user
|
||||
type FavoriteJob struct {
|
||||
ID int `json:"id" db:"id"`
|
||||
UserID int `json:"userId" db:"user_id"`
|
||||
JobID int `json:"jobId" db:"job_id"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
}
|
||||
|
||||
// FavoriteJobWithDetails includes job information
|
||||
type FavoriteJobWithDetails struct {
|
||||
FavoriteJob
|
||||
JobTitle string `json:"jobTitle"`
|
||||
CompanyID int `json:"companyId"`
|
||||
CompanyName string `json:"companyName"`
|
||||
CompanyLogoURL *string `json:"companyLogoUrl,omitempty"`
|
||||
Location *string `json:"location,omitempty"`
|
||||
SalaryMin *float64 `json:"salaryMin,omitempty"`
|
||||
SalaryMax *float64 `json:"salaryMax,omitempty"`
|
||||
SalaryType *string `json:"salaryType,omitempty"`
|
||||
}
|
||||
52
backend/internal/models/job.go
Executable file
|
|
@ -0,0 +1,52 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Job represents a job posting
|
||||
type Job struct {
|
||||
ID int `json:"id" db:"id"`
|
||||
CompanyID int `json:"companyId" db:"company_id"`
|
||||
CreatedBy int `json:"createdBy" db:"created_by"`
|
||||
|
||||
// Job Details
|
||||
Title string `json:"title" db:"title"`
|
||||
Description string `json:"description" db:"description"`
|
||||
|
||||
// Salary
|
||||
SalaryMin *float64 `json:"salaryMin,omitempty" db:"salary_min"`
|
||||
SalaryMax *float64 `json:"salaryMax,omitempty" db:"salary_max"`
|
||||
SalaryType *string `json:"salaryType,omitempty" db:"salary_type"` // hourly, monthly, yearly
|
||||
|
||||
// Employment
|
||||
EmploymentType *string `json:"employmentType,omitempty" db:"employment_type"` // full-time, part-time, dispatch, contract
|
||||
WorkingHours *string `json:"workingHours,omitempty" db:"working_hours"`
|
||||
|
||||
// Location
|
||||
Location *string `json:"location,omitempty" db:"location"`
|
||||
RegionID *int `json:"regionId,omitempty" db:"region_id"`
|
||||
CityID *int `json:"cityId,omitempty" db:"city_id"`
|
||||
|
||||
// Requirements & Benefits (JSONB arrays)
|
||||
Requirements JSONMap `json:"requirements,omitempty" db:"requirements"`
|
||||
Benefits JSONMap `json:"benefits,omitempty" db:"benefits"`
|
||||
|
||||
// Visa & Language
|
||||
VisaSupport bool `json:"visaSupport" db:"visa_support"`
|
||||
LanguageLevel *string `json:"languageLevel,omitempty" db:"language_level"` // N5-N1, beginner, none
|
||||
|
||||
// Status
|
||||
Status string `json:"status" db:"status"` // open, closed, draft
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
|
||||
// JobWithCompany includes company information
|
||||
type JobWithCompany struct {
|
||||
Job
|
||||
CompanyName string `json:"companyName"`
|
||||
CompanyLogoURL *string `json:"companyLogoUrl,omitempty"`
|
||||
RegionName *string `json:"regionName,omitempty"`
|
||||
CityName *string `json:"cityName,omitempty"`
|
||||
}
|
||||
26
backend/internal/models/location.go
Executable file
|
|
@ -0,0 +1,26 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Region represents a global region (State, Province, Prefecture)
|
||||
type Region struct {
|
||||
ID int `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
CountryCode string `json:"countryCode" db:"country_code"`
|
||||
Code string `json:"code" db:"code"` // ISO Code
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
}
|
||||
|
||||
// City represents a global city within a region
|
||||
type City struct {
|
||||
ID int `json:"id" db:"id"`
|
||||
RegionID int `json:"regionId" db:"region_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
}
|
||||
|
||||
// CityWithRegion includes region information
|
||||
type CityWithRegion struct {
|
||||
City
|
||||
RegionName string `json:"regionName,omitempty"`
|
||||
}
|
||||
13
backend/internal/models/password_reset.go
Executable file
|
|
@ -0,0 +1,13 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// PasswordReset represents a password reset token
|
||||
type PasswordReset struct {
|
||||
ID int `json:"id" db:"id"`
|
||||
UserID int `json:"userId" db:"user_id"`
|
||||
Token string `json:"token" db:"token"`
|
||||
ExpiresAt time.Time `json:"expiresAt" db:"expires_at"`
|
||||
Used bool `json:"used" db:"used"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
}
|
||||
61
backend/internal/models/user.go
Executable file
|
|
@ -0,0 +1,61 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// User represents a system user (SuperAdmin, CompanyAdmin, Recruiter, or JobSeeker)
|
||||
type User struct {
|
||||
ID int `json:"id" db:"id"`
|
||||
Identifier string `json:"identifier" db:"identifier"`
|
||||
PasswordHash string `json:"-" db:"password_hash"` // Never expose password hash in JSON
|
||||
Role string `json:"role" db:"role"` // superadmin, companyAdmin, recruiter, jobSeeker
|
||||
|
||||
// Personal Info
|
||||
FullName string `json:"fullName" db:"full_name"`
|
||||
Phone *string `json:"phone,omitempty" db:"phone"`
|
||||
LineID *string `json:"lineId,omitempty" db:"line_id"`
|
||||
WhatsApp *string `json:"whatsapp,omitempty" db:"whatsapp"`
|
||||
Instagram *string `json:"instagram,omitempty" db:"instagram"`
|
||||
|
||||
// Settings
|
||||
Language string `json:"language" db:"language"` // pt, en, es, ja
|
||||
Active bool `json:"active" db:"active"`
|
||||
|
||||
// Metadata
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt,omitempty" db:"last_login_at"`
|
||||
}
|
||||
|
||||
// UserResponse is the public representation of a user (without sensitive data)
|
||||
type UserResponse struct {
|
||||
ID int `json:"id"`
|
||||
Identifier string `json:"identifier"`
|
||||
Role string `json:"role"`
|
||||
FullName string `json:"fullName"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
LineID *string `json:"lineId,omitempty"`
|
||||
WhatsApp *string `json:"whatsapp,omitempty"`
|
||||
Instagram *string `json:"instagram,omitempty"`
|
||||
Language string `json:"language"`
|
||||
Active bool `json:"active"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
|
||||
}
|
||||
|
||||
// ToResponse converts User to UserResponse
|
||||
func (u *User) ToResponse() UserResponse {
|
||||
return UserResponse{
|
||||
ID: u.ID,
|
||||
Identifier: u.Identifier,
|
||||
Role: u.Role,
|
||||
FullName: u.FullName,
|
||||
Phone: u.Phone,
|
||||
LineID: u.LineID,
|
||||
WhatsApp: u.WhatsApp,
|
||||
Instagram: u.Instagram,
|
||||
Language: u.Language,
|
||||
Active: u.Active,
|
||||
CreatedAt: u.CreatedAt,
|
||||
LastLoginAt: u.LastLoginAt,
|
||||
}
|
||||
}
|
||||
50
backend/internal/models/user_company.go
Executable file
|
|
@ -0,0 +1,50 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UserCompany represents the N:M relationship between users and companies
|
||||
type UserCompany struct {
|
||||
ID int `json:"id" db:"id"`
|
||||
UserID int `json:"userId" db:"user_id"`
|
||||
CompanyID int `json:"companyId" db:"company_id"`
|
||||
Role string `json:"role" db:"role"` // companyAdmin, recruiter
|
||||
Permissions JSONMap `json:"permissions,omitempty" db:"permissions"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
}
|
||||
|
||||
// UserCompanyWithDetails includes user and company information
|
||||
type UserCompanyWithDetails struct {
|
||||
UserCompany
|
||||
UserName string `json:"userName,omitempty"`
|
||||
CompanyName string `json:"companyName,omitempty"`
|
||||
}
|
||||
|
||||
// JSONMap is a custom type for JSONB fields
|
||||
type JSONMap map[string]interface{}
|
||||
|
||||
// Value implements the driver.Valuer interface for JSON serialization
|
||||
func (j JSONMap) Value() (driver.Value, error) {
|
||||
if j == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(j)
|
||||
}
|
||||
|
||||
// Scan implements the sql.Scanner interface for JSON deserialization
|
||||
func (j *JSONMap) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*j = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return json.Unmarshal(bytes, j)
|
||||
}
|
||||
97
backend/internal/router/router.go
Executable file
|
|
@ -0,0 +1,97 @@
|
|||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/api/middleware"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/database"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/handlers"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/infrastructure/persistence/postgres"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/services"
|
||||
|
||||
// Core Imports
|
||||
apiHandlers "github.com/rede5/gohorsejobs/backend/internal/api/handlers"
|
||||
authUC "github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth"
|
||||
tenantUC "github.com/rede5/gohorsejobs/backend/internal/core/usecases/tenant"
|
||||
userUC "github.com/rede5/gohorsejobs/backend/internal/core/usecases/user"
|
||||
authInfra "github.com/rede5/gohorsejobs/backend/internal/infrastructure/auth"
|
||||
|
||||
httpSwagger "github.com/swaggo/http-swagger/v2"
|
||||
_ "github.com/rede5/gohorsejobs/backend/docs" // Import generated docs
|
||||
)
|
||||
|
||||
func NewRouter() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Initialize Services
|
||||
jobService := services.NewJobService(database.DB)
|
||||
applicationService := services.NewApplicationService(database.DB)
|
||||
|
||||
// --- CORE ARCHITECTURE INITIALIZATION ---
|
||||
// Infrastructure
|
||||
// Infrastructure,
|
||||
userRepo := postgres.NewUserRepository(database.DB)
|
||||
companyRepo := postgres.NewCompanyRepository(database.DB)
|
||||
|
||||
jwtSecret := os.Getenv("JWT_SECRET")
|
||||
if jwtSecret == "" {
|
||||
// Fallback for dev, but really should be in env
|
||||
jwtSecret = "default-dev-secret-do-not-use-in-prod"
|
||||
}
|
||||
|
||||
authService := authInfra.NewJWTService(jwtSecret, "todai-jobs")
|
||||
|
||||
// UseCases
|
||||
loginUC := authUC.NewLoginUseCase(userRepo, authService)
|
||||
createCompanyUC := tenantUC.NewCreateCompanyUseCase(companyRepo, userRepo, authService)
|
||||
createUserUC := userUC.NewCreateUserUseCase(userRepo, authService)
|
||||
listUsersUC := userUC.NewListUsersUseCase(userRepo)
|
||||
deleteUserUC := userUC.NewDeleteUserUseCase(userRepo)
|
||||
|
||||
// Handlers & Middleware
|
||||
coreHandlers := apiHandlers.NewCoreHandlers(loginUC, createCompanyUC, createUserUC, listUsersUC, deleteUserUC)
|
||||
authMiddleware := middleware.NewMiddleware(authService)
|
||||
|
||||
// Initialize Legacy Handlers
|
||||
jobHandler := handlers.NewJobHandler(jobService)
|
||||
applicationHandler := handlers.NewApplicationHandler(applicationService)
|
||||
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
// --- CORE ROUTES ---
|
||||
// Public
|
||||
mux.HandleFunc("POST /api/v1/auth/login", coreHandlers.Login)
|
||||
mux.HandleFunc("POST /api/v1/companies", coreHandlers.CreateCompany)
|
||||
|
||||
// Protected
|
||||
// Note: In Go 1.22+, we can wrap specific patterns. Or we can just wrap the handler.
|
||||
// For simplicity, we wrap the handler function.
|
||||
mux.Handle("POST /api/v1/users", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.CreateUser)))
|
||||
mux.Handle("GET /api/v1/users", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.ListUsers)))
|
||||
mux.Handle("DELETE /api/v1/users/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.DeleteUser)))
|
||||
|
||||
// Job Routes
|
||||
mux.HandleFunc("GET /jobs", jobHandler.GetJobs)
|
||||
mux.HandleFunc("POST /jobs", jobHandler.CreateJob)
|
||||
mux.HandleFunc("GET /jobs/{id}", jobHandler.GetJobByID)
|
||||
mux.HandleFunc("PUT /jobs/{id}", jobHandler.UpdateJob)
|
||||
mux.HandleFunc("DELETE /jobs/{id}", jobHandler.DeleteJob)
|
||||
|
||||
// Application Routes
|
||||
mux.HandleFunc("POST /applications", applicationHandler.CreateApplication)
|
||||
mux.HandleFunc("GET /applications", applicationHandler.GetApplications)
|
||||
mux.HandleFunc("GET /applications/{id}", applicationHandler.GetApplicationByID)
|
||||
mux.HandleFunc("PUT /applications/{id}/status", applicationHandler.UpdateApplicationStatus)
|
||||
|
||||
// Swagger Route
|
||||
mux.HandleFunc("/swagger/", httpSwagger.WrapHandler)
|
||||
|
||||
// Wrap the mux with CORS middleware
|
||||
handler := middleware.CORSMiddleware(mux)
|
||||
|
||||
return handler
|
||||
}
|
||||
114
backend/internal/services/application_service.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/dto"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/models"
|
||||
)
|
||||
|
||||
type ApplicationService struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func NewApplicationService(db *sql.DB) *ApplicationService {
|
||||
return &ApplicationService{DB: db}
|
||||
}
|
||||
|
||||
func (s *ApplicationService) CreateApplication(req dto.CreateApplicationRequest) (*models.Application, error) {
|
||||
query := `
|
||||
INSERT INTO applications (
|
||||
job_id, user_id, name, phone, line_id, whatsapp, email,
|
||||
message, resume_url, documents, status, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
app := &models.Application{
|
||||
JobID: req.JobID,
|
||||
UserID: req.UserID,
|
||||
Name: req.Name,
|
||||
Phone: req.Phone,
|
||||
LineID: req.LineID,
|
||||
WhatsApp: req.WhatsApp,
|
||||
Email: req.Email,
|
||||
Message: req.Message,
|
||||
ResumeURL: req.ResumeURL,
|
||||
Documents: req.Documents,
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
err := s.DB.QueryRow(
|
||||
query,
|
||||
app.JobID, app.UserID, app.Name, app.Phone, app.LineID, app.WhatsApp, app.Email,
|
||||
app.Message, app.ResumeURL, app.Documents, app.Status, app.CreatedAt, app.UpdatedAt,
|
||||
).Scan(&app.ID, &app.CreatedAt, &app.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (s *ApplicationService) GetApplications(jobID int) ([]models.Application, error) {
|
||||
// Simple get by Job ID
|
||||
query := `
|
||||
SELECT id, job_id, user_id, name, phone, line_id, whatsapp, email,
|
||||
message, resume_url, status, created_at, updated_at
|
||||
FROM applications WHERE job_id = $1
|
||||
`
|
||||
rows, err := s.DB.Query(query, jobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var apps []models.Application
|
||||
for rows.Next() {
|
||||
var a models.Application
|
||||
if err := rows.Scan(
|
||||
&a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email,
|
||||
&a.Message, &a.ResumeURL, &a.Status, &a.CreatedAt, &a.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apps = append(apps, a)
|
||||
}
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
func (s *ApplicationService) GetApplicationByID(id int) (*models.Application, error) {
|
||||
var a models.Application
|
||||
query := `
|
||||
SELECT id, job_id, user_id, name, phone, line_id, whatsapp, email,
|
||||
message, resume_url, documents, status, created_at, updated_at
|
||||
FROM applications WHERE id = $1
|
||||
`
|
||||
err := s.DB.QueryRow(query, id).Scan(
|
||||
&a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email,
|
||||
&a.Message, &a.ResumeURL, &a.Documents, &a.Status, &a.CreatedAt, &a.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *ApplicationService) UpdateApplicationStatus(id int, req dto.UpdateApplicationStatusRequest) (*models.Application, error) {
|
||||
query := `
|
||||
UPDATE applications SET status = $1, updated_at = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING updated_at
|
||||
`
|
||||
var updatedAt time.Time
|
||||
err := s.DB.QueryRow(query, req.Status, id).Scan(&updatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetApplicationByID(id)
|
||||
}
|
||||
181
backend/internal/services/job_service.go
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/dto"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/models"
|
||||
)
|
||||
|
||||
type JobService struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func NewJobService(db *sql.DB) *JobService {
|
||||
return &JobService{DB: db}
|
||||
}
|
||||
|
||||
func (s *JobService) CreateJob(req dto.CreateJobRequest) (*models.Job, error) {
|
||||
query := `
|
||||
INSERT INTO jobs (
|
||||
company_id, title, description, salary_min, salary_max, salary_type,
|
||||
employment_type, working_hours, location, region_id, city_id,
|
||||
requirements, benefits, visa_support, language_level, status, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
job := &models.Job{
|
||||
CompanyID: req.CompanyID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
SalaryMin: req.SalaryMin,
|
||||
SalaryMax: req.SalaryMax,
|
||||
SalaryType: req.SalaryType,
|
||||
EmploymentType: req.EmploymentType,
|
||||
WorkingHours: req.WorkingHours,
|
||||
Location: req.Location,
|
||||
RegionID: req.RegionID,
|
||||
CityID: req.CityID,
|
||||
Requirements: req.Requirements,
|
||||
Benefits: req.Benefits,
|
||||
VisaSupport: req.VisaSupport,
|
||||
LanguageLevel: req.LanguageLevel,
|
||||
Status: req.Status,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
err := s.DB.QueryRow(
|
||||
query,
|
||||
job.CompanyID, job.Title, job.Description, job.SalaryMin, job.SalaryMax, job.SalaryType,
|
||||
job.EmploymentType, job.WorkingHours, job.Location, job.RegionID, job.CityID,
|
||||
job.Requirements, job.Benefits, job.VisaSupport, job.LanguageLevel, job.Status, job.CreatedAt, job.UpdatedAt,
|
||||
).Scan(&job.ID, &job.CreatedAt, &job.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.Job, int, error) {
|
||||
baseQuery := `SELECT id, company_id, title, description, salary_min, salary_max, salary_type, employment_type, location, status, created_at, updated_at FROM jobs WHERE 1=1`
|
||||
countQuery := `SELECT COUNT(*) FROM jobs WHERE 1=1`
|
||||
|
||||
var args []interface{}
|
||||
argId := 1
|
||||
|
||||
if filter.CompanyID != nil {
|
||||
baseQuery += fmt.Sprintf(" AND company_id = $%d", argId)
|
||||
countQuery += fmt.Sprintf(" AND company_id = $%d", argId)
|
||||
args = append(args, *filter.CompanyID)
|
||||
argId++
|
||||
}
|
||||
|
||||
// Add more filters as needed...
|
||||
|
||||
// Pagination
|
||||
limit := filter.Limit
|
||||
if limit == 0 {
|
||||
limit = 10
|
||||
}
|
||||
offset := (filter.Page - 1) * limit
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
paginationQuery := baseQuery + fmt.Sprintf(" LIMIT $%d OFFSET $%d", argId, argId+1)
|
||||
paginationArgs := append(args, limit, offset)
|
||||
|
||||
rows, err := s.DB.Query(paginationQuery, paginationArgs...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var jobs []models.Job
|
||||
for rows.Next() {
|
||||
var j models.Job
|
||||
if err := rows.Scan(&j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType, &j.EmploymentType, &j.Location, &j.Status, &j.CreatedAt, &j.UpdatedAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
|
||||
var total int
|
||||
err = s.DB.QueryRow(countQuery, args...).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return jobs, total, nil
|
||||
}
|
||||
|
||||
func (s *JobService) GetJobByID(id int) (*models.Job, error) {
|
||||
var j models.Job
|
||||
query := `
|
||||
SELECT id, company_id, title, description, salary_min, salary_max, salary_type,
|
||||
employment_type, working_hours, location, region_id, city_id,
|
||||
requirements, benefits, visa_support, language_level, status, created_at, updated_at
|
||||
FROM jobs WHERE id = $1
|
||||
`
|
||||
err := s.DB.QueryRow(query, id).Scan(
|
||||
&j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType,
|
||||
&j.EmploymentType, &j.WorkingHours, &j.Location, &j.RegionID, &j.CityID,
|
||||
&j.Requirements, &j.Benefits, &j.VisaSupport, &j.LanguageLevel, &j.Status, &j.CreatedAt, &j.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &j, nil
|
||||
}
|
||||
|
||||
func (s *JobService) UpdateJob(id int, req dto.UpdateJobRequest) (*models.Job, error) {
|
||||
var setClauses []string
|
||||
var args []interface{}
|
||||
argId := 1
|
||||
|
||||
if req.Title != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("title = $%d", argId))
|
||||
args = append(args, *req.Title)
|
||||
argId++
|
||||
}
|
||||
if req.Description != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("description = $%d", argId))
|
||||
args = append(args, *req.Description)
|
||||
argId++
|
||||
}
|
||||
// Add other fields...
|
||||
if req.Status != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("status = $%d", argId))
|
||||
args = append(args, *req.Status)
|
||||
argId++
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
return s.GetJobByID(id)
|
||||
}
|
||||
|
||||
setClauses = append(setClauses, "updated_at = NOW()")
|
||||
|
||||
query := fmt.Sprintf("UPDATE jobs SET %s WHERE id = $%d RETURNING id, updated_at", strings.Join(setClauses, ", "), argId)
|
||||
args = append(args, id)
|
||||
|
||||
var j models.Job
|
||||
err := s.DB.QueryRow(query, args...).Scan(&j.ID, &j.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetJobByID(id)
|
||||
}
|
||||
|
||||
func (s *JobService) DeleteJob(id int) error {
|
||||
_, err := s.DB.Exec("DELETE FROM jobs WHERE id = $1", id)
|
||||
return err
|
||||
}
|
||||
58
backend/internal/utils/jwt.go
Executable file
|
|
@ -0,0 +1,58 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var jwtSecret = []byte("your-secret-key-change-this-in-production") // TODO: Move to env var
|
||||
|
||||
// Claims represents JWT custom claims
|
||||
type Claims struct {
|
||||
UserID int `json:"userId"`
|
||||
Identifier string `json:"identifier"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateJWT generates a new JWT token for a user
|
||||
func GenerateJWT(userID int, identifier string, role string) (string, error) {
|
||||
expirationTime := time.Now().Add(24 * time.Hour) // 24 hours
|
||||
|
||||
claims := &Claims{
|
||||
UserID: userID,
|
||||
Identifier: identifier,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(jwtSecret)
|
||||
}
|
||||
|
||||
// ValidateJWT validates a JWT token and returns the claims
|
||||
func ValidateJWT(tokenString string) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("invalid signing method")
|
||||
}
|
||||
return jwtSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
15
backend/internal/utils/password.go
Executable file
|
|
@ -0,0 +1,15 @@
|
|||
package utils
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
// HashPassword generates a bcrypt hash from a plain password
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// CheckPasswordHash compares a password with its hash
|
||||
func CheckPasswordHash(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
35
backend/migrations/001_create_users_table.sql
Executable file
|
|
@ -0,0 +1,35 @@
|
|||
-- Migration: Create users table
|
||||
-- Description: Stores all system users (SuperAdmin, CompanyAdmin, Recruiter, JobSeeker)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
identifier VARCHAR(100) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL CHECK (role IN ('superadmin', 'companyAdmin', 'recruiter', 'jobSeeker')),
|
||||
|
||||
-- Personal Info
|
||||
full_name VARCHAR(255),
|
||||
phone VARCHAR(30),
|
||||
line_id VARCHAR(100),
|
||||
whatsapp VARCHAR(30),
|
||||
instagram VARCHAR(100),
|
||||
|
||||
-- Settings
|
||||
language VARCHAR(5) DEFAULT 'en' CHECK (language IN ('pt', 'en', 'es', 'ja')),
|
||||
active BOOLEAN DEFAULT true,
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX idx_users_identifier ON users(identifier);
|
||||
CREATE INDEX idx_users_role ON users(role);
|
||||
CREATE INDEX idx_users_active ON users(active);
|
||||
|
||||
-- Comments for documentation
|
||||
COMMENT ON TABLE users IS 'Stores all system users across all roles';
|
||||
COMMENT ON COLUMN users.identifier IS 'Username for login (NOT email)';
|
||||
COMMENT ON COLUMN users.role IS 'User role: superadmin, companyAdmin, recruiter, or jobSeeker';
|
||||
41
backend/migrations/002_create_companies_table.sql
Executable file
|
|
@ -0,0 +1,41 @@
|
|||
-- Migration: Create companies table
|
||||
-- Description: Stores company information for employers
|
||||
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(255) UNIQUE NOT NULL, -- URL friendly name
|
||||
type VARCHAR(50) DEFAULT 'company', -- company, agency, etc
|
||||
document VARCHAR(100), -- Houjin Bangou (Company Number)
|
||||
|
||||
-- Contact
|
||||
address TEXT,
|
||||
region_id INT,
|
||||
city_id INT,
|
||||
phone VARCHAR(30),
|
||||
email VARCHAR(255),
|
||||
website VARCHAR(255),
|
||||
|
||||
-- Branding
|
||||
logo_url TEXT,
|
||||
description TEXT,
|
||||
|
||||
-- Status
|
||||
active BOOLEAN DEFAULT true,
|
||||
verified BOOLEAN DEFAULT false,
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_companies_active ON companies(active);
|
||||
CREATE INDEX idx_companies_verified ON companies(verified);
|
||||
CREATE INDEX idx_companies_region ON companies(region_id);
|
||||
CREATE INDEX idx_companies_slug ON companies(slug);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE companies IS 'Stores company/employer information';
|
||||
COMMENT ON COLUMN companies.document IS 'Japanese Company Number (Houjin Bangou)';
|
||||
COMMENT ON COLUMN companies.verified IS 'Whether company has been verified by admin';
|
||||
29
backend/migrations/003_create_user_companies_table.sql
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
-- Migration: Create user_companies pivot table
|
||||
-- Description: N:M relationship for multi-tenant - users can belong to multiple companies
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_companies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
company_id INT NOT NULL,
|
||||
role VARCHAR(20) NOT NULL CHECK (role IN ('companyAdmin', 'recruiter')),
|
||||
permissions JSONB, -- Optional granular permissions
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Foreign keys
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
|
||||
|
||||
-- Ensure unique user-company pairs
|
||||
UNIQUE(user_id, company_id)
|
||||
);
|
||||
|
||||
-- Indexes for efficient lookups
|
||||
CREATE INDEX idx_user_companies_user ON user_companies(user_id);
|
||||
CREATE INDEX idx_user_companies_company ON user_companies(company_id);
|
||||
CREATE INDEX idx_user_companies_role ON user_companies(role);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE user_companies IS 'Multi-tenant pivot: links users to companies with specific roles';
|
||||
COMMENT ON COLUMN user_companies.role IS 'Role within this company: companyAdmin or recruiter';
|
||||
COMMENT ON COLUMN user_companies.permissions IS 'Optional JSON object for granular permissions per company';
|
||||
29
backend/migrations/004_create_prefectures_cities_tables.sql
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
-- Migration: Create regions and cities tables
|
||||
-- Description: Global location data (Regions/States + Cities)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS regions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
country_code VARCHAR(10) NOT NULL, -- ISO Country (US, BR, JP, etc.)
|
||||
code VARCHAR(10), -- ISO Region code (e.g., SP, CA)
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cities (
|
||||
id SERIAL PRIMARY KEY,
|
||||
region_id INT NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (region_id) REFERENCES regions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_regions_country ON regions(country_code);
|
||||
CREATE INDEX idx_cities_region ON cities(region_id);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE regions IS 'Global Regions (States, Provinces, Prefectures)';
|
||||
COMMENT ON TABLE cities IS 'Global Cities by Region';
|
||||
COMMENT ON COLUMN regions.code IS 'ISO Region code (e.g., SP, CA)';
|
||||
62
backend/migrations/005_create_jobs_table.sql
Executable file
|
|
@ -0,0 +1,62 @@
|
|||
-- Migration: Create jobs table
|
||||
-- Description: Job postings created by companies
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_id INT NOT NULL,
|
||||
created_by INT NOT NULL, -- user who created the job
|
||||
|
||||
-- Job Details
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
|
||||
-- Salary
|
||||
salary_min DECIMAL(12,2),
|
||||
salary_max DECIMAL(12,2),
|
||||
salary_type VARCHAR(20) CHECK (salary_type IN ('hourly', 'monthly', 'yearly')),
|
||||
|
||||
-- Employment
|
||||
employment_type VARCHAR(30) CHECK (employment_type IN ('full-time', 'part-time', 'dispatch', 'contract')),
|
||||
working_hours VARCHAR(100),
|
||||
|
||||
-- Location
|
||||
location VARCHAR(255),
|
||||
region_id INT,
|
||||
city_id INT,
|
||||
|
||||
-- Requirements & Benefits (stored as JSON arrays)
|
||||
requirements JSONB,
|
||||
benefits JSONB,
|
||||
|
||||
-- Visa & Language
|
||||
visa_support BOOLEAN DEFAULT false,
|
||||
language_level VARCHAR(20), -- 'N5' | 'N4' | 'N3' | 'N2' | 'N1' | 'beginner' | 'none'
|
||||
|
||||
-- Status
|
||||
status VARCHAR(20) DEFAULT 'open' CHECK (status IN ('open', 'closed', 'draft')),
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Foreign keys
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id),
|
||||
FOREIGN KEY (region_id) REFERENCES regions(id),
|
||||
FOREIGN KEY (city_id) REFERENCES cities(id)
|
||||
);
|
||||
|
||||
-- Indexes for filtering and search
|
||||
CREATE INDEX idx_jobs_company ON jobs(company_id);
|
||||
CREATE INDEX idx_jobs_status ON jobs(status);
|
||||
CREATE INDEX idx_jobs_region ON jobs(region_id);
|
||||
CREATE INDEX idx_jobs_employment_type ON jobs(employment_type);
|
||||
CREATE INDEX idx_jobs_visa_support ON jobs(visa_support);
|
||||
CREATE INDEX idx_jobs_created_at ON jobs(created_at DESC);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE jobs IS 'Job postings created by companies';
|
||||
COMMENT ON COLUMN jobs.visa_support IS 'Whether company provides visa sponsorship';
|
||||
COMMENT ON COLUMN jobs.language_level IS 'Required Japanese language level (JLPT N5-N1, beginner, or none)';
|
||||
COMMENT ON COLUMN jobs.requirements IS 'JSON array of job requirements';
|
||||
COMMENT ON COLUMN jobs.benefits IS 'JSON array of job benefits';
|
||||
44
backend/migrations/006_create_applications_table.sql
Executable file
|
|
@ -0,0 +1,44 @@
|
|||
-- Migration: Create applications table
|
||||
-- Description: Job applications (can be from registered users or guests)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS applications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
job_id INT NOT NULL,
|
||||
user_id INT, -- NULL for guest applications
|
||||
|
||||
-- Applicant Info (required for guest applications)
|
||||
name VARCHAR(255),
|
||||
phone VARCHAR(30),
|
||||
line_id VARCHAR(100),
|
||||
whatsapp VARCHAR(30),
|
||||
email VARCHAR(255),
|
||||
|
||||
-- Application Content
|
||||
message TEXT,
|
||||
resume_url TEXT,
|
||||
documents JSONB, -- Array of document objects {type: string, url: string}
|
||||
|
||||
-- Status & Notes
|
||||
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'reviewed', 'shortlisted', 'rejected', 'hired')),
|
||||
notes TEXT, -- Recruiter notes
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Foreign keys
|
||||
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_applications_job ON applications(job_id);
|
||||
CREATE INDEX idx_applications_user ON applications(user_id);
|
||||
CREATE INDEX idx_applications_status ON applications(status);
|
||||
CREATE INDEX idx_applications_created_at ON applications(created_at DESC);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE applications IS 'Job applications from users or guests';
|
||||
COMMENT ON COLUMN applications.user_id IS 'NULL if guest application';
|
||||
COMMENT ON COLUMN applications.documents IS 'JSON array of uploaded documents (residence card, resume, etc.)';
|
||||
COMMENT ON COLUMN applications.notes IS 'Internal notes from recruiters';
|
||||
24
backend/migrations/007_create_favorite_jobs_table.sql
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
-- Migration: Create favorite_jobs table
|
||||
-- Description: Users can save favorite jobs for later
|
||||
|
||||
CREATE TABLE IF NOT EXISTS favorite_jobs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
job_id INT NOT NULL,
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Foreign keys
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
|
||||
-- Ensure user can't favorite same job twice
|
||||
UNIQUE(user_id, job_id)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_favorite_jobs_user ON favorite_jobs(user_id);
|
||||
CREATE INDEX idx_favorite_jobs_job ON favorite_jobs(job_id);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE favorite_jobs IS 'Saved/favorited jobs by users';
|
||||
25
backend/migrations/008_create_password_resets_table.sql
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
-- Migration: Create password_resets table
|
||||
-- Description: Password reset tokens
|
||||
|
||||
CREATE TABLE IF NOT EXISTS password_resets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
token VARCHAR(255) UNIQUE NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
used BOOLEAN DEFAULT false,
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Foreign key
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_password_resets_token ON password_resets(token);
|
||||
CREATE INDEX idx_password_resets_user ON password_resets(user_id);
|
||||
CREATE INDEX idx_password_resets_expires ON password_resets(expires_at);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE password_resets IS 'Password reset tokens with expiration';
|
||||
COMMENT ON COLUMN password_resets.token IS 'Unique reset token sent to user';
|
||||
COMMENT ON COLUMN password_resets.used IS 'Whether token has been used';
|
||||
33
backend/migrations/009_create_core_tables.sql
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
-- Migration: Create Core Architecture Tables
|
||||
-- Description: Agnostic tables for Multi-Tenant Architecture (UUID based)
|
||||
|
||||
-- Companies (Tenants)
|
||||
CREATE TABLE IF NOT EXISTS core_companies (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
document VARCHAR(50),
|
||||
contact VARCHAR(255),
|
||||
status VARCHAR(20) DEFAULT 'ACTIVE',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Users (Multi-Tenant)
|
||||
CREATE TABLE IF NOT EXISTS core_users (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
tenant_id VARCHAR(36) NOT NULL REFERENCES core_companies(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'ACTIVE',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_email_per_tenant UNIQUE (tenant_id, email)
|
||||
);
|
||||
|
||||
-- Permissions / Roles (Simplified JSON store or Relational? keeping it simple Relational)
|
||||
CREATE TABLE IF NOT EXISTS core_user_roles (
|
||||
user_id VARCHAR(36) NOT NULL REFERENCES core_users(id) ON DELETE CASCADE,
|
||||
role VARCHAR(50) NOT NULL,
|
||||
PRIMARY KEY (user_id, role)
|
||||
);
|
||||
36
backend/migrations/010_seed_super_admin.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
-- Migration: Create Super Admin and System Tenant
|
||||
-- Description: Inserts the default System Tenant and Super Admin user.
|
||||
-- Use fixed UUIDs for reproducibility in dev environments.
|
||||
|
||||
-- 1. Insert System Tenant
|
||||
INSERT INTO core_companies (id, name, document, contact, status, created_at, updated_at)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000001', -- Fixed System Tenant ID
|
||||
'System Tenant (SuperAdmin Context)',
|
||||
'SYSTEM',
|
||||
'admin@system.local',
|
||||
'ACTIVE',
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 2. Insert Super Admin User
|
||||
-- Password: "password123" (BCrypt hash)
|
||||
INSERT INTO core_users (id, tenant_id, name, email, password_hash, status, created_at, updated_at)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000002', -- Fixed SuperAdmin User ID
|
||||
'00000000-0000-0000-0000-000000000001', -- Link to System Tenant
|
||||
'Super Administrator',
|
||||
'admin@todai.jobs',
|
||||
'$2a$10$UWrE9xN39lVagJHlXZsxwOVI3NRSEd1VJ6UzMblW6LOxNmsOZtj9K', -- "password123"
|
||||
'ACTIVE',
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 3. Assign Super Admin Role
|
||||
INSERT INTO core_user_roles (user_id, role)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000002',
|
||||
'SUPER_ADMIN'
|
||||
) ON CONFLICT (user_id, role) DO NOTHING;
|
||||
25
backend/migrations/999_fix_gohorse_schema.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
|
||||
-- Fix Companies
|
||||
ALTER TABLE companies ADD COLUMN IF NOT EXISTS document VARCHAR(100);
|
||||
ALTER TABLE companies ADD COLUMN IF NOT EXISTS address TEXT;
|
||||
ALTER TABLE companies ADD COLUMN IF NOT EXISTS region_id INT;
|
||||
ALTER TABLE companies ADD COLUMN IF NOT EXISTS city_id INT;
|
||||
ALTER TABLE companies ADD COLUMN IF NOT EXISTS phone VARCHAR(30);
|
||||
ALTER TABLE companies ADD COLUMN IF NOT EXISTS email VARCHAR(255);
|
||||
-- website existed
|
||||
ALTER TABLE companies ADD COLUMN IF NOT EXISTS verified BOOLEAN DEFAULT false;
|
||||
ALTER TABLE companies ADD COLUMN IF NOT EXISTS active BOOLEAN DEFAULT true;
|
||||
-- Drop legacy constraints
|
||||
ALTER TABLE companies DROP CONSTRAINT IF EXISTS companies_type_check;
|
||||
|
||||
-- Fix Jobs
|
||||
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS salary_min DECIMAL(12,2);
|
||||
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS salary_max DECIMAL(12,2);
|
||||
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS salary_type VARCHAR(20);
|
||||
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS employment_type VARCHAR(30);
|
||||
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS working_hours VARCHAR(100);
|
||||
-- location existed
|
||||
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS visa_support BOOLEAN DEFAULT false;
|
||||
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS language_level VARCHAR(20);
|
||||
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS region_id INT;
|
||||
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS city_id INT;
|
||||
41
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
1
frontend/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
legacy-peer-deps=true
|
||||
48
frontend/README.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# GoHorseJobs Frontend
|
||||
|
||||
This is the frontend for the GoHorseJobs application, built with **Next.js 15**, **Tailwind CSS**, and **shadcn/ui**.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+
|
||||
- npm / yarn / pnpm
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### Running Development Server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser.
|
||||
|
||||
## 🛠️ Technology Stack
|
||||
|
||||
- **Framework:** [Next.js 15](https://nextjs.org/) (App Router)
|
||||
- **Styling:** [Tailwind CSS](https://tailwindcss.com/)
|
||||
- **Components:** [shadcn/ui](https://ui.shadcn.com/) (Radix UI)
|
||||
- **Icons:** [Lucide React](https://lucide.dev/)
|
||||
- **Forms:** React Hook Form + Zod
|
||||
- **State Management:** React Context / Hooks
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
- `/src/app` - App Router pages and layouts
|
||||
- `/src/components` - Reusable UI components
|
||||
- `/src/components/ui` - shadcn/ui primitives
|
||||
- `/src/hooks` - Custom React hooks
|
||||
- `/src/lib` - Utility functions and libraries
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
- **Company Dashboard:** Manage jobs, applications, and messages.
|
||||
- **Candidate Portal:** View and apply for jobs.
|
||||
- **Profile Management:** Local database integration for profile pictures.
|
||||
- **Responsive Design:** Mobile-first approach.
|
||||
22
frontend/components.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
25
frontend/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
7
frontend/next.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
5232
frontend/package-lock.json
generated
Normal file
75
frontend/package.json
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
{
|
||||
"name": "my-v0-project",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@radix-ui/react-accordion": "1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "1.1.4",
|
||||
"@radix-ui/react-aspect-ratio": "1.1.1",
|
||||
"@radix-ui/react-avatar": "1.1.2",
|
||||
"@radix-ui/react-checkbox": "1.1.3",
|
||||
"@radix-ui/react-collapsible": "1.1.2",
|
||||
"@radix-ui/react-context-menu": "2.2.4",
|
||||
"@radix-ui/react-dialog": "1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.4",
|
||||
"@radix-ui/react-hover-card": "1.1.4",
|
||||
"@radix-ui/react-label": "2.1.1",
|
||||
"@radix-ui/react-menubar": "1.1.4",
|
||||
"@radix-ui/react-navigation-menu": "1.2.3",
|
||||
"@radix-ui/react-popover": "1.1.4",
|
||||
"@radix-ui/react-progress": "1.1.1",
|
||||
"@radix-ui/react-radio-group": "1.2.2",
|
||||
"@radix-ui/react-scroll-area": "1.2.2",
|
||||
"@radix-ui/react-select": "2.1.4",
|
||||
"@radix-ui/react-separator": "1.1.1",
|
||||
"@radix-ui/react-slider": "1.2.2",
|
||||
"@radix-ui/react-slot": "1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "1.1.2",
|
||||
"@radix-ui/react-toast": "1.2.4",
|
||||
"@radix-ui/react-toggle": "1.1.1",
|
||||
"@radix-ui/react-toggle-group": "1.1.1",
|
||||
"@radix-ui/react-tooltip": "1.1.6",
|
||||
"@vercel/analytics": "1.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
"date-fns": "4.1.0",
|
||||
"embla-carousel-react": "8.5.1",
|
||||
"framer-motion": "12.23.22",
|
||||
"geist": "^1.3.1",
|
||||
"input-otp": "1.4.1",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "^15.5.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "9.8.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.4",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.9",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"eslint": "^9.39.1",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^4.1.9",
|
||||
"tw-animate-css": "1.3.3",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
5
frontend/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 994 B |
BIN
frontend/public/gohorse-jobs-logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/gohorse-logo-transparent.png
Normal file
|
After Width: | Height: | Size: 452 KiB |
BIN
frontend/public/gohorse-logo.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
frontend/public/gohorsejobs-logo.jpeg
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
frontend/public/logo_ghj.jpg
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
frontend/public/logo_tj.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
frontend/public/logohorse.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 91 KiB |
591
frontend/src/app/cadastro/candidato/page.tsx
Normal file
|
|
@ -0,0 +1,591 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Phone,
|
||||
MapPin,
|
||||
Calendar,
|
||||
GraduationCap,
|
||||
Briefcase,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { motion } from "framer-motion";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
const candidateSchema = z.object({
|
||||
fullName: z.string().min(2, "Nome deve ter pelo menos 2 caracteres"),
|
||||
email: z.string().email("Email inválido"),
|
||||
password: z.string().min(6, "Senha deve ter pelo menos 6 caracteres"),
|
||||
confirmPassword: z.string(),
|
||||
phone: z.string().min(10, "Telefone deve ter pelo menos 10 dígitos"),
|
||||
birthDate: z.string().min(1, "Data de nascimento é obrigatória"),
|
||||
address: z.string().min(5, "Endereço deve ter pelo menos 5 caracteres"),
|
||||
city: z.string().min(2, "Cidade é obrigatória"),
|
||||
state: z.string().min(2, "Estado é obrigatório"),
|
||||
zipCode: z.string().min(8, "CEP deve ter 8 dígitos"),
|
||||
education: z.string().min(1, "Nível de escolaridade é obrigatório"),
|
||||
experience: z.string().min(1, "Experiência profissional é obrigatória"),
|
||||
skills: z.string().optional(),
|
||||
objective: z.string().optional(),
|
||||
acceptTerms: z.boolean().refine(val => val === true, "Você deve aceitar os termos"),
|
||||
acceptNewsletter: z.boolean().optional(),
|
||||
}).refine(data => data.password === data.confirmPassword, {
|
||||
message: "Senhas não coincidem",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
type CandidateFormData = z.infer<typeof candidateSchema>;
|
||||
|
||||
export default function CandidateRegisterPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm<CandidateFormData>({
|
||||
resolver: zodResolver(candidateSchema),
|
||||
});
|
||||
|
||||
const acceptTerms = watch("acceptTerms");
|
||||
const acceptNewsletter = watch("acceptNewsletter");
|
||||
|
||||
const onSubmit = async (data: CandidateFormData) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Simular cadastro
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
console.log("Dados do candidato:", data);
|
||||
|
||||
// Redirecionar para login após cadastro
|
||||
router.push("/login?message=Cadastro realizado com sucesso! Faça login para continuar.");
|
||||
} catch (error) {
|
||||
console.error("Erro no cadastro:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep < 3) setCurrentStep(currentStep + 1);
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep > 1) setCurrentStep(currentStep - 1);
|
||||
};
|
||||
|
||||
const stepVariants = {
|
||||
hidden: { opacity: 0, x: 20 },
|
||||
visible: { opacity: 1, x: 0 },
|
||||
exit: { opacity: 0, x: -20 }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-background to-muted/20 flex">
|
||||
{/* Left Panel - Informações */}
|
||||
<div className="hidden lg:flex lg:flex-1 bg-gradient-to-br from-primary to-primary/80 p-8 flex-col justify-center items-center text-primary-foreground">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="max-w-md text-center"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-3 mb-8">
|
||||
<Image src="/logohorse.png" alt="GoHorse Jobs" width={80} height={80} className="rounded-lg" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl font-bold mb-4">
|
||||
Cadastre-se como Candidato
|
||||
</h1>
|
||||
|
||||
<p className="text-lg opacity-90 leading-relaxed mb-6">
|
||||
Crie sua conta e tenha acesso às melhores oportunidades de emprego.
|
||||
Encontre a vaga dos seus sonhos hoje mesmo!
|
||||
</p>
|
||||
|
||||
<div className="space-y-4 text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
<span>Acesso a milhares de vagas</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
<span>Candidaturas rápidas e fáceis</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
<span>Perfil profissional completo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
<span>Notificações de novas oportunidades</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Formulário */}
|
||||
<div className="flex-1 p-8 flex flex-col justify-center">
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground mb-4 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Voltar ao Login
|
||||
</Link>
|
||||
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">
|
||||
Criar Conta - Candidato
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Preencha seus dados para criar sua conta
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Indicator */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">Etapa {currentStep} de 3</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{currentStep === 1 && "Dados Pessoais"}
|
||||
{currentStep === 2 && "Endereço e Contato"}
|
||||
{currentStep === 3 && "Perfil Profissional"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${(currentStep / 3) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Step 1: Dados Pessoais */}
|
||||
{currentStep === 1 && (
|
||||
<motion.div
|
||||
key="step1"
|
||||
variants={stepVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fullName">Nome Completo</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="fullName"
|
||||
type="text"
|
||||
placeholder="Seu nome completo"
|
||||
className="pl-10"
|
||||
{...register("fullName")}
|
||||
/>
|
||||
</div>
|
||||
{errors.fullName && (
|
||||
<span className="text-sm text-destructive">{errors.fullName.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="seu@email.com"
|
||||
className="pl-10"
|
||||
{...register("email")}
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<span className="text-sm text-destructive">{errors.email.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Senha</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Sua senha"
|
||||
className="pl-10 pr-10"
|
||||
{...register("password")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<span className="text-sm text-destructive">{errors.password.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirmar Senha</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="Confirme sua senha"
|
||||
className="pl-10 pr-10"
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<span className="text-sm text-destructive">{errors.confirmPassword.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="birthDate">Data de Nascimento</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="birthDate"
|
||||
type="date"
|
||||
className="pl-10"
|
||||
{...register("birthDate")}
|
||||
/>
|
||||
</div>
|
||||
{errors.birthDate && (
|
||||
<span className="text-sm text-destructive">{errors.birthDate.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={nextStep} className="w-full">
|
||||
Próxima Etapa
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Endereço e Contato */}
|
||||
{currentStep === 2 && (
|
||||
<motion.div
|
||||
key="step2"
|
||||
variants={stepVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Telefone</Label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="(11) 99999-9999"
|
||||
className="pl-10"
|
||||
{...register("phone")}
|
||||
/>
|
||||
</div>
|
||||
{errors.phone && (
|
||||
<span className="text-sm text-destructive">{errors.phone.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address">Endereço</Label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="address"
|
||||
type="text"
|
||||
placeholder="Rua, número, complemento"
|
||||
className="pl-10"
|
||||
{...register("address")}
|
||||
/>
|
||||
</div>
|
||||
{errors.address && (
|
||||
<span className="text-sm text-destructive">{errors.address.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Cidade</Label>
|
||||
<Input
|
||||
id="city"
|
||||
type="text"
|
||||
placeholder="Sua cidade"
|
||||
{...register("city")}
|
||||
/>
|
||||
{errors.city && (
|
||||
<span className="text-sm text-destructive">{errors.city.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="state">Estado</Label>
|
||||
<Select onValueChange={(value) => setValue("state", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Estado" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AC">Acre</SelectItem>
|
||||
<SelectItem value="AL">Alagoas</SelectItem>
|
||||
<SelectItem value="AP">Amapá</SelectItem>
|
||||
<SelectItem value="AM">Amazonas</SelectItem>
|
||||
<SelectItem value="BA">Bahia</SelectItem>
|
||||
<SelectItem value="CE">Ceará</SelectItem>
|
||||
<SelectItem value="DF">Distrito Federal</SelectItem>
|
||||
<SelectItem value="ES">Espírito Santo</SelectItem>
|
||||
<SelectItem value="GO">Goiás</SelectItem>
|
||||
<SelectItem value="MA">Maranhão</SelectItem>
|
||||
<SelectItem value="MT">Mato Grosso</SelectItem>
|
||||
<SelectItem value="MS">Mato Grosso do Sul</SelectItem>
|
||||
<SelectItem value="MG">Minas Gerais</SelectItem>
|
||||
<SelectItem value="PA">Pará</SelectItem>
|
||||
<SelectItem value="PB">Paraíba</SelectItem>
|
||||
<SelectItem value="PR">Paraná</SelectItem>
|
||||
<SelectItem value="PE">Pernambuco</SelectItem>
|
||||
<SelectItem value="PI">Piauí</SelectItem>
|
||||
<SelectItem value="RJ">Rio de Janeiro</SelectItem>
|
||||
<SelectItem value="RN">Rio Grande do Norte</SelectItem>
|
||||
<SelectItem value="RS">Rio Grande do Sul</SelectItem>
|
||||
<SelectItem value="RO">Rondônia</SelectItem>
|
||||
<SelectItem value="RR">Roraima</SelectItem>
|
||||
<SelectItem value="SC">Santa Catarina</SelectItem>
|
||||
<SelectItem value="SP">São Paulo</SelectItem>
|
||||
<SelectItem value="SE">Sergipe</SelectItem>
|
||||
<SelectItem value="TO">Tocantins</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.state && (
|
||||
<span className="text-sm text-destructive">{errors.state.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="zipCode">CEP</Label>
|
||||
<Input
|
||||
id="zipCode"
|
||||
type="text"
|
||||
placeholder="00000-000"
|
||||
{...register("zipCode")}
|
||||
/>
|
||||
{errors.zipCode && (
|
||||
<span className="text-sm text-destructive">{errors.zipCode.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button type="button" variant="outline" onClick={prevStep} className="flex-1">
|
||||
Voltar
|
||||
</Button>
|
||||
<Button type="button" onClick={nextStep} className="flex-1">
|
||||
Próxima Etapa
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Perfil Profissional */}
|
||||
{currentStep === 3 && (
|
||||
<motion.div
|
||||
key="step3"
|
||||
variants={stepVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="education">Nível de Escolaridade</Label>
|
||||
<Select onValueChange={(value) => setValue("education", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione sua escolaridade" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fundamental">Ensino Fundamental</SelectItem>
|
||||
<SelectItem value="medio">Ensino Médio</SelectItem>
|
||||
<SelectItem value="tecnico">Técnico</SelectItem>
|
||||
<SelectItem value="superior">Ensino Superior</SelectItem>
|
||||
<SelectItem value="pos">Pós-graduação</SelectItem>
|
||||
<SelectItem value="mestrado">Mestrado</SelectItem>
|
||||
<SelectItem value="doutorado">Doutorado</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.education && (
|
||||
<span className="text-sm text-destructive">{errors.education.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="experience">Experiência Profissional</Label>
|
||||
<Select onValueChange={(value) => setValue("experience", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione sua experiência" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sem-experiencia">Sem experiência</SelectItem>
|
||||
<SelectItem value="ate-1-ano">Até 1 ano</SelectItem>
|
||||
<SelectItem value="1-2-anos">1 a 2 anos</SelectItem>
|
||||
<SelectItem value="2-5-anos">2 a 5 anos</SelectItem>
|
||||
<SelectItem value="5-10-anos">5 a 10 anos</SelectItem>
|
||||
<SelectItem value="mais-10-anos">Mais de 10 anos</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.experience && (
|
||||
<span className="text-sm text-destructive">{errors.experience.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skills">Habilidades e Competências (opcional)</Label>
|
||||
<Textarea
|
||||
id="skills"
|
||||
placeholder="Ex: JavaScript, React, Photoshop, Inglês fluente..."
|
||||
className="min-h-[80px]"
|
||||
{...register("skills")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="objective">Objetivo Profissional (opcional)</Label>
|
||||
<Textarea
|
||||
id="objective"
|
||||
placeholder="Descreva seus objetivos e o que busca em uma oportunidade..."
|
||||
className="min-h-[80px]"
|
||||
{...register("objective")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id="acceptTerms"
|
||||
checked={acceptTerms}
|
||||
onCheckedChange={(checked) => setValue("acceptTerms", checked as boolean)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="acceptTerms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Aceito os{" "}
|
||||
<Link href="/termos" className="text-primary hover:underline">
|
||||
Termos de Uso
|
||||
</Link>{" "}
|
||||
e{" "}
|
||||
<Link href="/privacidade" className="text-primary hover:underline">
|
||||
Política de Privacidade
|
||||
</Link>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{errors.acceptTerms && (
|
||||
<span className="text-sm text-destructive">{errors.acceptTerms.message}</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id="acceptNewsletter"
|
||||
checked={acceptNewsletter}
|
||||
onCheckedChange={(checked) => setValue("acceptNewsletter", checked as boolean)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="acceptNewsletter"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Quero receber notificações sobre novas vagas por email
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button type="button" variant="outline" onClick={prevStep} className="flex-1">
|
||||
Voltar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading} className="flex-1">
|
||||
{loading ? "Criando conta..." : "Criar Conta"}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Já tem uma conta?{" "}
|
||||
<Link href="/login" className="text-primary hover:underline font-medium">
|
||||
Faça login
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
639
frontend/src/app/cadastro/empresa/page.tsx
Normal file
|
|
@ -0,0 +1,639 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Building2,
|
||||
Mail,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Phone,
|
||||
MapPin,
|
||||
Users,
|
||||
Globe,
|
||||
FileText,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { motion } from "framer-motion";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
const companySchema = z.object({
|
||||
companyName: z.string().min(2, "Nome da empresa deve ter pelo menos 2 caracteres"),
|
||||
cnpj: z.string().min(14, "CNPJ deve ter 14 dígitos"),
|
||||
email: z.string().email("Email inválido"),
|
||||
password: z.string().min(6, "Senha deve ter pelo menos 6 caracteres"),
|
||||
confirmPassword: z.string(),
|
||||
phone: z.string().min(10, "Telefone deve ter pelo menos 10 dígitos"),
|
||||
website: z.string().url("Website deve ser uma URL válida").optional().or(z.literal("")),
|
||||
address: z.string().min(5, "Endereço deve ter pelo menos 5 caracteres"),
|
||||
city: z.string().min(2, "Cidade é obrigatória"),
|
||||
state: z.string().min(2, "Estado é obrigatório"),
|
||||
zipCode: z.string().min(8, "CEP deve ter 8 dígitos"),
|
||||
sector: z.string().min(1, "Setor de atuação é obrigatório"),
|
||||
companySize: z.string().min(1, "Tamanho da empresa é obrigatório"),
|
||||
description: z.string().min(20, "Descrição deve ter pelo menos 20 caracteres"),
|
||||
contactPerson: z.string().min(2, "Nome do responsável é obrigatório"),
|
||||
contactRole: z.string().min(2, "Cargo do responsável é obrigatório"),
|
||||
acceptTerms: z.boolean().refine(val => val === true, "Você deve aceitar os termos"),
|
||||
acceptNewsletter: z.boolean().optional(),
|
||||
}).refine(data => data.password === data.confirmPassword, {
|
||||
message: "Senhas não coincidem",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
type CompanyFormData = z.infer<typeof companySchema>;
|
||||
|
||||
export default function CompanyRegisterPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm<CompanyFormData>({
|
||||
resolver: zodResolver(companySchema),
|
||||
});
|
||||
|
||||
const acceptTerms = watch("acceptTerms");
|
||||
const acceptNewsletter = watch("acceptNewsletter");
|
||||
|
||||
const onSubmit = async (data: CompanyFormData) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Simular cadastro
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
console.log("Dados da empresa:", data);
|
||||
|
||||
// Redirecionar para login após cadastro
|
||||
router.push("/login?message=Cadastro realizado com sucesso! Faça login para continuar.");
|
||||
} catch (error) {
|
||||
console.error("Erro no cadastro:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep < 3) setCurrentStep(currentStep + 1);
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep > 1) setCurrentStep(currentStep - 1);
|
||||
};
|
||||
|
||||
const stepVariants = {
|
||||
hidden: { opacity: 0, x: 20 },
|
||||
visible: { opacity: 1, x: 0 },
|
||||
exit: { opacity: 0, x: -20 }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-background to-muted/20 flex">
|
||||
{/* Left Panel - Informações */}
|
||||
<div className="hidden lg:flex lg:flex-1 bg-gradient-to-br from-primary to-primary/80 p-8 flex-col justify-center items-center text-primary-foreground">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="max-w-md text-center"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-3 mb-8">
|
||||
<Image src="/logohorse.png" alt="GoHorse Jobs" width={80} height={80} className="rounded-lg" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl font-bold mb-4">
|
||||
Cadastre sua Empresa
|
||||
</h1>
|
||||
|
||||
<p className="text-lg opacity-90 leading-relaxed mb-6">
|
||||
Encontre os melhores talentos para sua empresa.
|
||||
Publique vagas e conecte-se com candidatos qualificados.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4 text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
<span>Publique vagas gratuitamente</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
<span>Acesso a milhares de candidatos</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
<span>Ferramentas de gestão de candidaturas</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
<span>Dashboard completo de recrutamento</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Formulário */}
|
||||
<div className="flex-1 p-8 flex flex-col justify-center">
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground mb-4 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Voltar ao Login
|
||||
</Link>
|
||||
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">
|
||||
Criar Conta - Empresa
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Preencha os dados da sua empresa
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Indicator */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">Etapa {currentStep} de 3</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{currentStep === 1 && "Dados da Empresa"}
|
||||
{currentStep === 2 && "Endereço e Contato"}
|
||||
{currentStep === 3 && "Informações Adicionais"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${(currentStep / 3) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Step 1: Dados da Empresa */}
|
||||
{currentStep === 1 && (
|
||||
<motion.div
|
||||
key="step1"
|
||||
variants={stepVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="companyName">Nome da Empresa</Label>
|
||||
<div className="relative">
|
||||
<Building2 className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="companyName"
|
||||
type="text"
|
||||
placeholder="Nome da sua empresa"
|
||||
className="pl-10"
|
||||
{...register("companyName")}
|
||||
/>
|
||||
</div>
|
||||
{errors.companyName && (
|
||||
<span className="text-sm text-destructive">{errors.companyName.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cnpj">CNPJ</Label>
|
||||
<div className="relative">
|
||||
<FileText className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="cnpj"
|
||||
type="text"
|
||||
placeholder="00.000.000/0000-00"
|
||||
className="pl-10"
|
||||
{...register("cnpj")}
|
||||
/>
|
||||
</div>
|
||||
{errors.cnpj && (
|
||||
<span className="text-sm text-destructive">{errors.cnpj.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email Corporativo</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="contato@empresa.com"
|
||||
className="pl-10"
|
||||
{...register("email")}
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<span className="text-sm text-destructive">{errors.email.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Senha</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Sua senha"
|
||||
className="pl-10 pr-10"
|
||||
{...register("password")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<span className="text-sm text-destructive">{errors.password.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirmar Senha</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="Confirme sua senha"
|
||||
className="pl-10 pr-10"
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<span className="text-sm text-destructive">{errors.confirmPassword.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={nextStep} className="w-full">
|
||||
Próxima Etapa
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Endereço e Contato */}
|
||||
{currentStep === 2 && (
|
||||
<motion.div
|
||||
key="step2"
|
||||
variants={stepVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Telefone</Label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="(11) 99999-9999"
|
||||
className="pl-10"
|
||||
{...register("phone")}
|
||||
/>
|
||||
</div>
|
||||
{errors.phone && (
|
||||
<span className="text-sm text-destructive">{errors.phone.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">Website (opcional)</Label>
|
||||
<div className="relative">
|
||||
<Globe className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="website"
|
||||
type="url"
|
||||
placeholder="https://www.empresa.com"
|
||||
className="pl-10"
|
||||
{...register("website")}
|
||||
/>
|
||||
</div>
|
||||
{errors.website && (
|
||||
<span className="text-sm text-destructive">{errors.website.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address">Endereço</Label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="address"
|
||||
type="text"
|
||||
placeholder="Rua, número, complemento"
|
||||
className="pl-10"
|
||||
{...register("address")}
|
||||
/>
|
||||
</div>
|
||||
{errors.address && (
|
||||
<span className="text-sm text-destructive">{errors.address.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Cidade</Label>
|
||||
<Input
|
||||
id="city"
|
||||
type="text"
|
||||
placeholder="Sua cidade"
|
||||
{...register("city")}
|
||||
/>
|
||||
{errors.city && (
|
||||
<span className="text-sm text-destructive">{errors.city.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="state">Estado</Label>
|
||||
<Select onValueChange={(value) => setValue("state", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Estado" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AC">Acre</SelectItem>
|
||||
<SelectItem value="AL">Alagoas</SelectItem>
|
||||
<SelectItem value="AP">Amapá</SelectItem>
|
||||
<SelectItem value="AM">Amazonas</SelectItem>
|
||||
<SelectItem value="BA">Bahia</SelectItem>
|
||||
<SelectItem value="CE">Ceará</SelectItem>
|
||||
<SelectItem value="DF">Distrito Federal</SelectItem>
|
||||
<SelectItem value="ES">Espírito Santo</SelectItem>
|
||||
<SelectItem value="GO">Goiás</SelectItem>
|
||||
<SelectItem value="MA">Maranhão</SelectItem>
|
||||
<SelectItem value="MT">Mato Grosso</SelectItem>
|
||||
<SelectItem value="MS">Mato Grosso do Sul</SelectItem>
|
||||
<SelectItem value="MG">Minas Gerais</SelectItem>
|
||||
<SelectItem value="PA">Pará</SelectItem>
|
||||
<SelectItem value="PB">Paraíba</SelectItem>
|
||||
<SelectItem value="PR">Paraná</SelectItem>
|
||||
<SelectItem value="PE">Pernambuco</SelectItem>
|
||||
<SelectItem value="PI">Piauí</SelectItem>
|
||||
<SelectItem value="RJ">Rio de Janeiro</SelectItem>
|
||||
<SelectItem value="RN">Rio Grande do Norte</SelectItem>
|
||||
<SelectItem value="RS">Rio Grande do Sul</SelectItem>
|
||||
<SelectItem value="RO">Rondônia</SelectItem>
|
||||
<SelectItem value="RR">Roraima</SelectItem>
|
||||
<SelectItem value="SC">Santa Catarina</SelectItem>
|
||||
<SelectItem value="SP">São Paulo</SelectItem>
|
||||
<SelectItem value="SE">Sergipe</SelectItem>
|
||||
<SelectItem value="TO">Tocantins</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.state && (
|
||||
<span className="text-sm text-destructive">{errors.state.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="zipCode">CEP</Label>
|
||||
<Input
|
||||
id="zipCode"
|
||||
type="text"
|
||||
placeholder="00000-000"
|
||||
{...register("zipCode")}
|
||||
/>
|
||||
{errors.zipCode && (
|
||||
<span className="text-sm text-destructive">{errors.zipCode.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button type="button" variant="outline" onClick={prevStep} className="flex-1">
|
||||
Voltar
|
||||
</Button>
|
||||
<Button type="button" onClick={nextStep} className="flex-1">
|
||||
Próxima Etapa
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Informações Adicionais */}
|
||||
{currentStep === 3 && (
|
||||
<motion.div
|
||||
key="step3"
|
||||
variants={stepVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sector">Setor de Atuação</Label>
|
||||
<Select onValueChange={(value) => setValue("sector", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o setor" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tecnologia">Tecnologia</SelectItem>
|
||||
<SelectItem value="financeiro">Financeiro</SelectItem>
|
||||
<SelectItem value="saude">Saúde</SelectItem>
|
||||
<SelectItem value="educacao">Educação</SelectItem>
|
||||
<SelectItem value="varejo">Varejo</SelectItem>
|
||||
<SelectItem value="construcao">Construção</SelectItem>
|
||||
<SelectItem value="industria">Indústria</SelectItem>
|
||||
<SelectItem value="servicos">Serviços</SelectItem>
|
||||
<SelectItem value="agricultura">Agricultura</SelectItem>
|
||||
<SelectItem value="transporte">Transporte</SelectItem>
|
||||
<SelectItem value="energia">Energia</SelectItem>
|
||||
<SelectItem value="consultoria">Consultoria</SelectItem>
|
||||
<SelectItem value="marketing">Marketing</SelectItem>
|
||||
<SelectItem value="outros">Outros</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.sector && (
|
||||
<span className="text-sm text-destructive">{errors.sector.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="companySize">Tamanho da Empresa</Label>
|
||||
<Select onValueChange={(value) => setValue("companySize", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Número de funcionários" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1-10">1 a 10 funcionários</SelectItem>
|
||||
<SelectItem value="11-50">11 a 50 funcionários</SelectItem>
|
||||
<SelectItem value="51-200">51 a 200 funcionários</SelectItem>
|
||||
<SelectItem value="201-500">201 a 500 funcionários</SelectItem>
|
||||
<SelectItem value="501-1000">501 a 1000 funcionários</SelectItem>
|
||||
<SelectItem value="1000+">Mais de 1000 funcionários</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.companySize && (
|
||||
<span className="text-sm text-destructive">{errors.companySize.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Descrição da Empresa</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Descreva sua empresa, cultura, valores e o que oferece aos funcionários..."
|
||||
className="min-h-[100px]"
|
||||
{...register("description")}
|
||||
/>
|
||||
{errors.description && (
|
||||
<span className="text-sm text-destructive">{errors.description.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactPerson">Nome do Responsável</Label>
|
||||
<Input
|
||||
id="contactPerson"
|
||||
type="text"
|
||||
placeholder="Nome completo"
|
||||
{...register("contactPerson")}
|
||||
/>
|
||||
{errors.contactPerson && (
|
||||
<span className="text-sm text-destructive">{errors.contactPerson.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactRole">Cargo</Label>
|
||||
<Input
|
||||
id="contactRole"
|
||||
type="text"
|
||||
placeholder="Ex: RH, Gerente"
|
||||
{...register("contactRole")}
|
||||
/>
|
||||
{errors.contactRole && (
|
||||
<span className="text-sm text-destructive">{errors.contactRole.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id="acceptTerms"
|
||||
checked={acceptTerms}
|
||||
onCheckedChange={(checked) => setValue("acceptTerms", checked as boolean)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="acceptTerms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Aceito os{" "}
|
||||
<Link href="/termos" className="text-primary hover:underline">
|
||||
Termos de Uso
|
||||
</Link>{" "}
|
||||
e{" "}
|
||||
<Link href="/privacidade" className="text-primary hover:underline">
|
||||
Política de Privacidade
|
||||
</Link>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{errors.acceptTerms && (
|
||||
<span className="text-sm text-destructive">{errors.acceptTerms.message}</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id="acceptNewsletter"
|
||||
checked={acceptNewsletter}
|
||||
onCheckedChange={(checked) => setValue("acceptNewsletter", checked as boolean)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="acceptNewsletter"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Quero receber dicas de recrutamento e novidades por email
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button type="button" variant="outline" onClick={prevStep} className="flex-1">
|
||||
Voltar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading} className="flex-1">
|
||||
{loading ? "Criando conta..." : "Criar Conta"}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Já tem uma conta?{" "}
|
||||
<Link href="/login" className="text-primary hover:underline font-medium">
|
||||
Faça login
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
frontend/src/app/contato/page.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Navbar } from "@/components/navbar"
|
||||
import { Footer } from "@/components/footer"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Mail, MessageSquare, Phone, MapPin } from "lucide-react"
|
||||
|
||||
export default function ContatoPage() {
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitted(true)
|
||||
setTimeout(() => setSubmitted(false), 3000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
|
||||
<main className="flex-1">
|
||||
{/* Hero Section */}
|
||||
<section className="bg-muted/30 py-16 md:py-24">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-foreground mb-6 text-balance">Entre em Contato</h1>
|
||||
<p className="text-lg text-muted-foreground text-pretty">
|
||||
Tem alguma dúvida ou sugestão? Estamos aqui para ajudar. Entre em contato conosco.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact Section */}
|
||||
<section className="py-16 md:py-24">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-5xl mx-auto grid md:grid-cols-2 gap-12">
|
||||
{/* Contact Form */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Envie uma mensagem</CardTitle>
|
||||
<CardDescription>Preencha o formulário e retornaremos em breve</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Nome completo</Label>
|
||||
<Input id="name" placeholder="Seu nome" required />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-mail</Label>
|
||||
<Input id="email" type="email" placeholder="seu@email.com" required />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">Assunto</Label>
|
||||
<Input id="subject" placeholder="Como podemos ajudar?" required />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message">Mensagem</Label>
|
||||
<Textarea id="message" placeholder="Descreva sua dúvida ou sugestão..." rows={5} required />
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full cursor-pointer" disabled={submitted}>
|
||||
{submitted ? "Mensagem enviada!" : "Enviar mensagem"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Contact Info */}
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-6">Outras formas de contato</h2>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 rounded-lg bg-primary/10">
|
||||
<Mail className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">E-mail</h3>
|
||||
<p className="text-sm text-muted-foreground">contato@portalempregos.com</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 rounded-lg bg-primary/10">
|
||||
<Phone className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">Telefone</h3>
|
||||
<p className="text-sm text-muted-foreground">(11) 9999-9999</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 rounded-lg bg-primary/10">
|
||||
<MapPin className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">Endereço</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Av. Paulista, 1000
|
||||
<br />
|
||||
São Paulo, SP - 01310-100
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 rounded-lg bg-primary/10">
|
||||
<MessageSquare className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">Suporte</h3>
|
||||
<p className="text-sm text-muted-foreground">Segunda a Sexta, 9h às 18h</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold mb-2">Perguntas Frequentes</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Antes de entrar em contato, confira nossa seção de perguntas frequentes. Talvez sua dúvida já
|
||||
esteja respondida lá.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full cursor-pointer bg-transparent">
|
||||
Ver FAQ
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
222
frontend/src/app/dashboard/admin/candidates/page.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { AdminSidebar } from "@/components/admin-sidebar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Search, Eye, Mail, Phone, MapPin, Briefcase } from "lucide-react"
|
||||
import { mockCandidates } from "@/lib/mock-data"
|
||||
|
||||
export default function AdminCandidatesPage() {
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [selectedCandidate, setSelectedCandidate] = useState<any>(null)
|
||||
|
||||
const filteredCandidates = mockCandidates.filter(
|
||||
(candidate) =>
|
||||
candidate.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
candidate.email.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
<AdminSidebar />
|
||||
|
||||
<main className="flex-1 p-8">
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Gestão de Candidatos</h1>
|
||||
<p className="text-muted-foreground mt-1">Visualize e gerencie todos os candidatos cadastrados</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Total de Candidatos</CardDescription>
|
||||
<CardTitle className="text-3xl">{mockCandidates.length}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Novos (30 dias)</CardDescription>
|
||||
<CardTitle className="text-3xl">24</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Candidaturas Ativas</CardDescription>
|
||||
<CardTitle className="text-3xl">{"49"}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Taxa de Contratação</CardDescription>
|
||||
<CardTitle className="text-3xl">8%</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Search and Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar candidatos por nome ou email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Candidato</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Telefone</TableHead>
|
||||
<TableHead>Localização</TableHead>
|
||||
<TableHead>Candidaturas</TableHead>
|
||||
<TableHead className="text-right">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCandidates.map((candidate) => (
|
||||
<TableRow key={candidate.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
<div>
|
||||
<div className="font-medium">{candidate.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{candidate.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{candidate.email}</TableCell>
|
||||
<TableCell>{candidate.phone}</TableCell>
|
||||
<TableCell>{candidate.location}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{candidate.applications.length}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={() => setSelectedCandidate(candidate)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Perfil do Candidato</DialogTitle>
|
||||
<DialogDescription>Informações detalhadas sobre {candidate.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedCandidate && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<Avatar className="h-20 w-20">
|
||||
<AvatarImage src={selectedCandidate.avatar || "/placeholder.svg"} />
|
||||
<AvatarFallback>
|
||||
{selectedCandidate.name
|
||||
.split(" ")
|
||||
.map((n: string) => n[0])
|
||||
.join("")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold">{selectedCandidate.name}</h3>
|
||||
<p className="text-muted-foreground">{selectedCandidate.title}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{selectedCandidate.skills.map((skill: string) => (
|
||||
<Badge key={skill} variant="secondary">
|
||||
{skill}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{selectedCandidate.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{selectedCandidate.phone}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{selectedCandidate.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Briefcase className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{selectedCandidate.experience}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">Sobre</h4>
|
||||
<p className="text-sm text-muted-foreground">{selectedCandidate.bio}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">Candidaturas Recentes</h4>
|
||||
<div className="space-y-2">
|
||||
{selectedCandidate.applications.map((app: any) => (
|
||||
<div
|
||||
key={app.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-sm">{app.jobTitle}</div>
|
||||
<div className="text-xs text-muted-foreground">{app.company}</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
app.status === "accepted"
|
||||
? "default"
|
||||
: app.status === "rejected"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{app.status === "pending" && "Pendente"}
|
||||
{app.status === "accepted" && "Aceito"}
|
||||
{app.status === "rejected" && "Rejeitado"}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
165
frontend/src/app/dashboard/admin/candidatos/page.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { AdminSidebar } from "@/components/admin-sidebar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Search, Mail, Eye, UserCheck } from "lucide-react"
|
||||
|
||||
const mockCandidates = [
|
||||
{
|
||||
id: "1",
|
||||
name: "João Silva",
|
||||
email: "joao@example.com",
|
||||
area: "Desenvolvimento Full Stack",
|
||||
applications: 3,
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Maria Santos",
|
||||
email: "maria@example.com",
|
||||
area: "Design UX/UI",
|
||||
applications: 5,
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Carlos Oliveira",
|
||||
email: "carlos@example.com",
|
||||
area: "Engenharia de Dados",
|
||||
applications: 2,
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "Ana Costa",
|
||||
email: "ana@example.com",
|
||||
area: "Product Management",
|
||||
applications: 4,
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "Pedro Alves",
|
||||
email: "pedro@example.com",
|
||||
area: "Desenvolvimento Mobile",
|
||||
applications: 6,
|
||||
status: "active",
|
||||
},
|
||||
]
|
||||
|
||||
export default function AdminCandidatosPage() {
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
|
||||
const filteredCandidates = mockCandidates.filter(
|
||||
(candidate) =>
|
||||
candidate.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
candidate.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
candidate.area.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
<AdminSidebar />
|
||||
|
||||
<main className="flex-1 p-8">
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">Gestão de Candidatos</h1>
|
||||
<p className="text-muted-foreground">Visualize e gerencie todos os candidatos</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar candidatos..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-foreground">1,834</div>
|
||||
<p className="text-sm text-muted-foreground">Total de candidatos</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-foreground">156</div>
|
||||
<p className="text-sm text-muted-foreground">Novos este mês</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-foreground">89</div>
|
||||
<p className="text-sm text-muted-foreground">Com candidaturas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-foreground">23</div>
|
||||
<p className="text-sm text-muted-foreground">Em entrevista</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Candidates List */}
|
||||
<div className="space-y-4">
|
||||
{filteredCandidates.map((candidate) => (
|
||||
<Card key={candidate.id}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-4">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarImage src="/placeholder.svg" />
|
||||
<AvatarFallback>{candidate.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-foreground">{candidate.name}</h3>
|
||||
<Badge variant="secondary">Ativo</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
{candidate.email}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCheck className="h-4 w-4" />
|
||||
{candidate.area}
|
||||
</div>
|
||||
<div>{candidate.applications} candidaturas</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Ver perfil
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Contatar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
223
frontend/src/app/dashboard/admin/jobs/page.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { AdminSidebar } from "@/components/admin-sidebar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Plus, Search, Edit, Trash2, Eye } from "lucide-react"
|
||||
import { mockJobs } from "@/lib/mock-data"
|
||||
|
||||
export default function AdminJobsPage() {
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [jobs, setJobs] = useState(mockJobs)
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
|
||||
const filteredJobs = jobs.filter(
|
||||
(job) =>
|
||||
job.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
job.company.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
)
|
||||
|
||||
const handleDeleteJob = (id: string) => {
|
||||
setJobs(jobs.filter((job) => job.id !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
<AdminSidebar />
|
||||
|
||||
<main className="flex-1 p-8">
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Gestão de Vagas</h1>
|
||||
<p className="text-muted-foreground mt-1">Gerencie todas as vagas publicadas na plataforma</p>
|
||||
</div>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Nova Vaga
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Criar Nova Vaga</DialogTitle>
|
||||
<DialogDescription>Preencha os detalhes da nova vaga de emprego</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">Título da Vaga</Label>
|
||||
<Input id="title" placeholder="Ex: Desenvolvedor Full Stack" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="company">Empresa</Label>
|
||||
<Input id="company" placeholder="Nome da empresa" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="location">Localização</Label>
|
||||
<Input id="location" placeholder="São Paulo, SP" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="type">Tipo</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full-time">Tempo Integral</SelectItem>
|
||||
<SelectItem value="part-time">Meio Período</SelectItem>
|
||||
<SelectItem value="contract">Contrato</SelectItem>
|
||||
<SelectItem value="Remoto">Remoto</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="salary">Salário</Label>
|
||||
<Input id="salary" placeholder="R$ 8.000 - R$ 12.000" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="level">Nível</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="junior">Júnior</SelectItem>
|
||||
<SelectItem value="pleno">Pleno</SelectItem>
|
||||
<SelectItem value="senior">Sênior</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Descrição</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Descreva as responsabilidades e requisitos da vaga..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => setIsDialogOpen(false)}>Publicar Vaga</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Total de Vagas</CardDescription>
|
||||
<CardTitle className="text-3xl">{jobs.length}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Vagas Ativas</CardDescription>
|
||||
<CardTitle className="text-3xl">{jobs.length}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Candidaturas</CardDescription>
|
||||
<CardTitle className="text-3xl">{"436"}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Taxa de Conversão</CardDescription>
|
||||
<CardTitle className="text-3xl">12%</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar vagas por título ou empresa..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Vaga</TableHead>
|
||||
<TableHead>Empresa</TableHead>
|
||||
<TableHead>Localização</TableHead>
|
||||
<TableHead>Tipo</TableHead>
|
||||
<TableHead>Candidaturas</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredJobs.map((job) => (
|
||||
<TableRow key={job.id}>
|
||||
<TableCell className="font-medium">{job.title}</TableCell>
|
||||
<TableCell>{job.company}</TableCell>
|
||||
<TableCell>{job.location}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{job.type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{Math.floor(Math.random() * 50) + 10}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="default">Ativa</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDeleteJob(job.id)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
234
frontend/src/app/dashboard/admin/messages/page.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { AdminSidebar } from "@/components/admin-sidebar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Search, Send, Paperclip } from "lucide-react"
|
||||
|
||||
const mockConversations = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Ana Silva",
|
||||
avatar: "/professional-woman-diverse.png",
|
||||
lastMessage: "Obrigada pela resposta sobre a vaga!",
|
||||
timestamp: "10:30",
|
||||
unread: 2,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Carlos Santos",
|
||||
avatar: "/professional-man.jpg",
|
||||
lastMessage: "Quando posso esperar um retorno?",
|
||||
timestamp: "Ontem",
|
||||
unread: 0,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Maria Oliveira",
|
||||
avatar: "/professional-woman-smiling.png",
|
||||
lastMessage: "Gostaria de mais informações sobre os benefícios",
|
||||
timestamp: "2 dias",
|
||||
unread: 1,
|
||||
},
|
||||
]
|
||||
|
||||
const mockMessages = [
|
||||
{
|
||||
id: "1",
|
||||
sender: "Ana Silva",
|
||||
content: "Olá! Gostaria de saber mais sobre a vaga de Desenvolvedor Full Stack.",
|
||||
timestamp: "10:15",
|
||||
isAdmin: false,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
sender: "Você",
|
||||
content: "Olá Ana! Claro, ficarei feliz em ajudar. A vaga é para trabalho remoto e oferece benefícios completos.",
|
||||
timestamp: "10:20",
|
||||
isAdmin: true,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
sender: "Ana Silva",
|
||||
content: "Obrigada pela resposta sobre a vaga!",
|
||||
timestamp: "10:30",
|
||||
isAdmin: false,
|
||||
},
|
||||
]
|
||||
|
||||
export default function AdminMessagesPage() {
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [selectedConversation, setSelectedConversation] = useState(mockConversations[0])
|
||||
const [messageText, setMessageText] = useState("")
|
||||
|
||||
const filteredConversations = mockConversations.filter((conv) =>
|
||||
conv.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
)
|
||||
|
||||
const handleSendMessage = () => {
|
||||
if (messageText.trim()) {
|
||||
console.log("[v0] Sending message:", messageText)
|
||||
setMessageText("")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
<AdminSidebar />
|
||||
|
||||
<main className="flex-1 p-8">
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Mensagens</h1>
|
||||
<p className="text-muted-foreground mt-1">Comunique-se com candidatos e empresas</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Total de Conversas</CardDescription>
|
||||
<CardTitle className="text-3xl">{mockConversations.length}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Não Lidas</CardDescription>
|
||||
<CardTitle className="text-3xl">
|
||||
{mockConversations.reduce((acc, conv) => acc + conv.unread, 0)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Respondidas Hoje</CardDescription>
|
||||
<CardTitle className="text-3xl">12</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Tempo Médio de Resposta</CardDescription>
|
||||
<CardTitle className="text-3xl">2h</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Messages Interface */}
|
||||
<Card className="h-[600px]">
|
||||
<div className="grid grid-cols-[350px_1fr] h-full">
|
||||
{/* Conversations List */}
|
||||
<div className="border-r border-border">
|
||||
<CardHeader className="border-b border-border">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar conversas..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<ScrollArea className="h-[calc(600px-80px)]">
|
||||
<div className="p-2">
|
||||
{filteredConversations.map((conversation) => (
|
||||
<button
|
||||
key={conversation.id}
|
||||
onClick={() => setSelectedConversation(conversation)}
|
||||
className={`w-full flex items-start gap-3 p-3 rounded-lg hover:bg-muted transition-colors ${
|
||||
selectedConversation.id === conversation.id ? "bg-muted" : ""
|
||||
}`}
|
||||
>
|
||||
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-medium text-sm truncate">{conversation.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{conversation.timestamp}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground truncate">{conversation.lastMessage}</p>
|
||||
{conversation.unread > 0 && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="ml-2 h-5 w-5 p-0 flex items-center justify-center rounded-full"
|
||||
>
|
||||
{conversation.unread}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Chat Area */}
|
||||
<div className="flex flex-col">
|
||||
{/* Chat Header */}
|
||||
<CardHeader className="border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
<div>
|
||||
<CardTitle className="text-base">{selectedConversation.name}</CardTitle>
|
||||
<CardDescription className="text-xs">Online</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-4">
|
||||
{mockMessages.map((message) => (
|
||||
<div key={message.id} className={`flex ${message.isAdmin ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className={`max-w-[70%] rounded-lg p-3 ${
|
||||
message.isAdmin ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm">{message.content}</p>
|
||||
<span className="text-xs opacity-70 mt-1 block">{message.timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Message Input */}
|
||||
<div className="border-t border-border p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
<Button variant="outline" size="icon">
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
<Textarea
|
||||
placeholder="Digite sua mensagem..."
|
||||
value={messageText}
|
||||
onChange={(e) => setMessageText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}}
|
||||
className="min-h-[60px] resize-none"
|
||||
/>
|
||||
<Button onClick={handleSendMessage} size="icon">
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
218
frontend/src/app/dashboard/admin/page.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { DashboardHeader } from "@/components/dashboard-header"
|
||||
import { AdminSidebar } from "@/components/admin-sidebar"
|
||||
import { StatsCard } from "@/components/stats-card"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { mockStats, mockJobs } from "@/lib/mock-data"
|
||||
import { Briefcase, Users, TrendingUp, FileText, Plus, MoreHorizontal } from "lucide-react"
|
||||
import { motion } from "framer-motion"
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, ResponsiveContainer } from "recharts"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||
|
||||
const chartData = [
|
||||
{ month: "Jan", applications: 45 },
|
||||
{ month: "Fev", applications: 52 },
|
||||
{ month: "Mar", applications: 61 },
|
||||
{ month: "Abr", applications: 73 },
|
||||
{ month: "Mai", applications: 89 },
|
||||
{ month: "Jun", applications: 94 },
|
||||
]
|
||||
|
||||
const chartConfig = {
|
||||
applications: {
|
||||
label: "Candidaturas",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
}
|
||||
|
||||
const mockCandidates = [
|
||||
{ id: "1", name: "João Silva", position: "Desenvolvedor Full Stack", status: "active" },
|
||||
{ id: "2", name: "Maria Santos", position: "Designer UX/UI", status: "active" },
|
||||
{ id: "3", name: "Carlos Oliveira", position: "Product Manager", status: "pending" },
|
||||
{ id: "4", name: "Ana Costa", position: "Engenheiro de Dados", status: "active" },
|
||||
{ id: "5", name: "Pedro Alves", position: "DevOps Engineer", status: "inactive" },
|
||||
]
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const router = useRouter()
|
||||
const [user, setUser] = useState(getCurrentUser())
|
||||
|
||||
useEffect(() => {
|
||||
const currentUser = getCurrentUser()
|
||||
if (!currentUser || currentUser.role !== "admin") {
|
||||
router.push("/login")
|
||||
} else {
|
||||
setUser(currentUser)
|
||||
}
|
||||
}, [router])
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<DashboardHeader />
|
||||
|
||||
<div className="flex">
|
||||
<AdminSidebar />
|
||||
|
||||
<main className="flex-1 p-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<h1 className="text-3xl font-bold mb-2">Dashboard</h1>
|
||||
<p className="text-muted-foreground">Visão geral do portal de empregos</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"
|
||||
>
|
||||
<StatsCard
|
||||
title="Vagas Ativas"
|
||||
value={mockStats.activeJobs}
|
||||
icon={Briefcase}
|
||||
description="Total de vagas publicadas"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Total de Candidatos"
|
||||
value={mockStats.totalCandidates}
|
||||
icon={Users}
|
||||
description="Usuários cadastrados"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Novas Candidaturas"
|
||||
value={mockStats.newApplications}
|
||||
icon={FileText}
|
||||
description="Últimos 7 dias"
|
||||
/>
|
||||
<StatsCard title="Taxa de Conversão" value="12.5%" icon={TrendingUp} description="Candidaturas por vaga" />
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Chart */}
|
||||
|
||||
|
||||
{/* Recent Activity */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
>
|
||||
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Jobs Management */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="mt-8"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Gestão de Vagas</CardTitle>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Adicionar Vaga
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Título</TableHead>
|
||||
<TableHead>Empresa</TableHead>
|
||||
<TableHead>Localização</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Candidaturas</TableHead>
|
||||
<TableHead className="text-right">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mockJobs.slice(0, 5).map((job) => (
|
||||
<TableRow key={job.id}>
|
||||
<TableCell className="font-medium">{job.title}</TableCell>
|
||||
<TableCell>{job.company}</TableCell>
|
||||
<TableCell>{job.location}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">Ativa</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{Math.floor(Math.random() * 50) + 10}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Candidates Management */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
className="mt-8"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gestão de Candidatos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead>Cargo Pretendido</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mockCandidates.map((candidate) => (
|
||||
<TableRow key={candidate.id}>
|
||||
<TableCell className="font-medium">{candidate.name}</TableCell>
|
||||
<TableCell>{candidate.position}</TableCell>
|
||||
<TableCell>
|
||||
{candidate.status === "active" && <Badge className="bg-green-500">Ativo</Badge>}
|
||||
{candidate.status === "pending" && <Badge variant="secondary">Pendente</Badge>}
|
||||
{candidate.status === "inactive" && <Badge variant="outline">Inativo</Badge>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
177
frontend/src/app/dashboard/admin/profile/edit/page.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { AdminSidebar } from "@/components/admin-sidebar"
|
||||
import { DashboardHeader } from "@/components/dashboard-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { ArrowLeft, Upload } from "lucide-react"
|
||||
import { mockAdminUser } from "@/lib/mock-data"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
|
||||
export default function EditAdminProfilePage() {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [formData, setFormData] = useState({
|
||||
name: mockAdminUser.name,
|
||||
email: mockAdminUser.email,
|
||||
phone: mockAdminUser.phone,
|
||||
department: mockAdminUser.department,
|
||||
position: mockAdminUser.position,
|
||||
bio: mockAdminUser.bio,
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Simulate saving
|
||||
toast({
|
||||
title: "Perfil atualizado",
|
||||
description: "Suas informações foram salvas com sucesso.",
|
||||
})
|
||||
|
||||
router.push("/dashboard/admin/profile")
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<DashboardHeader />
|
||||
<div className="flex">
|
||||
<AdminSidebar />
|
||||
<main className="flex-1 p-8">
|
||||
<div className="max-w-3xl mx-auto space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-balance">Editar Perfil</h1>
|
||||
<p className="text-muted-foreground mt-1">Atualize suas informações pessoais</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Avatar Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Foto de Perfil</CardTitle>
|
||||
<CardDescription>Atualize sua foto de perfil</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-6">
|
||||
<Avatar className="h-24 w-24">
|
||||
<AvatarImage src={mockAdminUser.avatar || "/placeholder.svg"} alt={formData.name} />
|
||||
<AvatarFallback className="text-2xl">{formData.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<Button type="button" variant="outline">
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Carregar nova foto
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Personal Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informações Pessoais</CardTitle>
|
||||
<CardDescription>Atualize seus dados pessoais</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Nome Completo</Label>
|
||||
<Input id="name" name="name" value={formData.name} onChange={handleChange} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Telefone</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="+55 11 99999-9999"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Professional Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informações Profissionais</CardTitle>
|
||||
<CardDescription>Atualize seus dados profissionais</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="position">Cargo</Label>
|
||||
<Input id="position" name="position" value={formData.position} onChange={handleChange} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="department">Departamento</Label>
|
||||
<Input
|
||||
id="department"
|
||||
name="department"
|
||||
value={formData.department}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio">Sobre</Label>
|
||||
<Textarea
|
||||
id="bio"
|
||||
name="bio"
|
||||
value={formData.bio}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
placeholder="Conte um pouco sobre sua experiência profissional..."
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-4 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit">Salvar Alterações</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
165
frontend/src/app/dashboard/admin/profile/page.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { AdminSidebar } from "@/components/admin-sidebar"
|
||||
import { DashboardHeader } from "@/components/dashboard-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Mail, Phone, Calendar, Briefcase, Edit } from "lucide-react"
|
||||
import { mockAdminUser } from "@/lib/mock-data"
|
||||
|
||||
export default function AdminProfilePage() {
|
||||
const router = useRouter()
|
||||
const [admin] = useState(mockAdminUser)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<DashboardHeader />
|
||||
<div className="flex">
|
||||
<AdminSidebar />
|
||||
<main className="flex-1 p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Profile Header */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col md:flex-row gap-6 items-start">
|
||||
<Avatar className="h-32 w-32">
|
||||
<AvatarImage src={admin.avatar || "/placeholder.svg"} alt={admin.name} />
|
||||
<AvatarFallback className="text-3xl">{admin.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-balance">Olá, {admin.name}!</h1>
|
||||
<p className="text-lg text-muted-foreground mt-1">{admin.position}</p>
|
||||
</div>
|
||||
<Button onClick={() => router.push("/dashboard/admin/profile/edit")} className="cursor-pointer">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Editar Perfil
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
{admin.email}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="h-4 w-4" />
|
||||
{admin.phone}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
{admin.department}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Desde {new Date(admin.joinedAt).toLocaleDateString("pt-BR", { month: "long", year: "numeric" })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bio Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sobre</CardTitle>
|
||||
<CardDescription>Informações profissionais</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground leading-relaxed">{admin.bio}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stats Section */}
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Vagas Ativas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">156</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">+12 este mês</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Candidatos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">1,834</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">+234 este mês</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Contratações</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">47</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">+8 este mês</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Atividade Recente</CardTitle>
|
||||
<CardDescription>Suas ações mais recentes na plataforma</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
action: "Publicou nova vaga",
|
||||
detail: "Desenvolvedor Full Stack Sênior",
|
||||
time: "Há 2 horas",
|
||||
},
|
||||
{
|
||||
action: "Aprovou candidato",
|
||||
detail: "Ana Silva para Designer UX/UI",
|
||||
time: "Há 5 horas",
|
||||
},
|
||||
{
|
||||
action: "Respondeu mensagem",
|
||||
detail: "Carlos Santos",
|
||||
time: "Ontem",
|
||||
},
|
||||
{
|
||||
action: "Atualizou vaga",
|
||||
detail: "Engenheiro de Dados",
|
||||
time: "Há 2 dias",
|
||||
},
|
||||
].map((activity, index) => (
|
||||
<div key={index}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{activity.action}</p>
|
||||
<p className="text-sm text-muted-foreground">{activity.detail}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{activity.time}
|
||||
</Badge>
|
||||
</div>
|
||||
{index < 3 && <Separator className="mt-4" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
131
frontend/src/app/dashboard/admin/vagas/page.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { AdminSidebar } from "@/components/admin-sidebar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { mockJobs } from "@/lib/mock-data"
|
||||
import { Search, Plus, Edit, Trash2, Eye, MapPin, DollarSign } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function AdminVagasPage() {
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
|
||||
const filteredJobs = mockJobs.filter(
|
||||
(job) =>
|
||||
job.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
job.company.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
<AdminSidebar />
|
||||
|
||||
<main className="flex-1 p-8">
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">Gestão de Vagas</h1>
|
||||
<p className="text-muted-foreground">Gerencie todas as vagas publicadas</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nova vaga
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar vagas..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-foreground">{mockJobs.length}</div>
|
||||
<p className="text-sm text-muted-foreground">Total de vagas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-foreground">{mockJobs.length}</div>
|
||||
<p className="text-sm text-muted-foreground">Vagas ativas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-foreground">89</div>
|
||||
<p className="text-sm text-muted-foreground">Candidaturas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-foreground">12</div>
|
||||
<p className="text-sm text-muted-foreground">Vagas preenchidas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Jobs List */}
|
||||
<div className="space-y-4">
|
||||
{filteredJobs.map((job) => (
|
||||
<Card key={job.id}>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start gap-3 mb-2">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-xl">{job.title}</CardTitle>
|
||||
<CardDescription>{job.company}</CardDescription>
|
||||
</div>
|
||||
<Badge>Ativa</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
{job.location}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4" />
|
||||
{job.salary}
|
||||
</div>
|
||||
<div>Publicado: {new Date(job.postedAt).toLocaleDateString("pt-BR")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href={`/vagas/${job.id}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Visualizar
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Editar
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Excluir
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
186
frontend/src/app/dashboard/candidato/candidaturas/page.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"use client";
|
||||
|
||||
import { DashboardHeader } from "@/components/dashboard-header";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { mockApplications, mockJobs } from "@/lib/mock-data";
|
||||
import {
|
||||
Search,
|
||||
Calendar,
|
||||
Building2,
|
||||
MapPin,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
const statusMap = {
|
||||
pending: { label: "Pendente", variant: "secondary" as const },
|
||||
reviewing: { label: "Em análise", variant: "default" as const },
|
||||
interview: { label: "Entrevista", variant: "default" as const },
|
||||
rejected: { label: "Rejeitado", variant: "destructive" as const },
|
||||
accepted: { label: "Aceito", variant: "default" as const },
|
||||
};
|
||||
|
||||
export default function CandidaturasPage() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const filteredApplications = mockApplications.filter(
|
||||
(app) =>
|
||||
app.jobTitle.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
app.company.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<DashboardHeader />
|
||||
|
||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">
|
||||
Minhas Candidaturas
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Acompanhe o status das suas candidaturas
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-full md:w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar candidaturas..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{mockApplications.length}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{
|
||||
mockApplications.filter((a) => a.status === "reviewing")
|
||||
.length
|
||||
}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Em análise</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{
|
||||
mockApplications.filter((a) => a.status === "interview")
|
||||
.length
|
||||
}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Entrevistas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{
|
||||
mockApplications.filter((a) => a.status === "pending")
|
||||
.length
|
||||
}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Pendentes</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Applications List */}
|
||||
<div className="space-y-4">
|
||||
{filteredApplications.length > 0 ? (
|
||||
filteredApplications.map((application) => {
|
||||
const job = mockJobs.find((j) => j.id === application.jobId);
|
||||
const status = statusMap[application.status];
|
||||
|
||||
return (
|
||||
<Card key={application.id}>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-xl mb-1">
|
||||
{application.jobTitle}
|
||||
</CardTitle>
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
{application.company}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={status.variant}>
|
||||
{status.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Candidatura:{" "}
|
||||
{new Date(application.appliedAt).toLocaleDateString(
|
||||
"pt-BR"
|
||||
)}
|
||||
</div>
|
||||
{job && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
{job.location}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Link href={`/vagas/${application.jobId}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
Ver vaga
|
||||
<ExternalLink className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Nenhuma candidatura encontrada.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
279
frontend/src/app/dashboard/candidato/notificacoes/page.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
"use client";
|
||||
|
||||
import { DashboardHeader } from "@/components/dashboard-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Bell,
|
||||
Check,
|
||||
CheckCheck,
|
||||
Trash2,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Info,
|
||||
AlertTriangle,
|
||||
Archive,
|
||||
} from "lucide-react";
|
||||
import { useNotifications } from "@/contexts/notification-context";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
removeNotification,
|
||||
clearAllNotifications,
|
||||
} = useNotifications();
|
||||
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||
case "error":
|
||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||
case "warning":
|
||||
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
|
||||
default:
|
||||
return <Info className="h-5 w-5 text-blue-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getNotificationBgColor = (type: string, read: boolean) => {
|
||||
if (read) return "bg-muted/50";
|
||||
|
||||
switch (type) {
|
||||
case "success":
|
||||
return "bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900";
|
||||
case "error":
|
||||
return "bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-900";
|
||||
case "warning":
|
||||
return "bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-900";
|
||||
default:
|
||||
return "bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-900";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<DashboardHeader />
|
||||
|
||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-foreground mb-2">
|
||||
Notificações
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{unreadCount > 0 ? (
|
||||
<>
|
||||
Você tem{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{unreadCount}
|
||||
</span>{" "}
|
||||
{unreadCount === 1
|
||||
? "notificação não lida"
|
||||
: "notificações não lidas"}
|
||||
</>
|
||||
) : (
|
||||
"Você está em dia com suas notificações"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{notifications.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={markAllAsRead}
|
||||
className="gap-2"
|
||||
>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
Marcar todas como lidas
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearAllNotifications}
|
||||
className="gap-2 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Limpar todas
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notifications List */}
|
||||
{notifications.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Bell className="h-16 w-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
Nenhuma notificação
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Quando você receber notificações, elas aparecerão aqui
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{notifications.map((notification, index) => (
|
||||
<motion.div
|
||||
key={notification.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<Card
|
||||
className={`relative overflow-hidden transition-all hover:shadow-md ${getNotificationBgColor(
|
||||
notification.type,
|
||||
notification.read
|
||||
)}`}
|
||||
>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Icon */}
|
||||
<div className="shrink-0 mt-1">
|
||||
{getNotificationIcon(notification.type)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3
|
||||
className={`font-semibold text-base mb-1 ${
|
||||
notification.read
|
||||
? "text-muted-foreground"
|
||||
: "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{notification.title}
|
||||
</h3>
|
||||
<p
|
||||
className={`text-sm leading-relaxed ${
|
||||
notification.read
|
||||
? "text-muted-foreground"
|
||||
: "text-foreground/80"
|
||||
}`}
|
||||
>
|
||||
{notification.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Unread indicator */}
|
||||
{!notification.read && (
|
||||
<div className="shrink-0">
|
||||
<div className="h-2 w-2 bg-primary rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between gap-4 pt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(
|
||||
new Date(notification.createdAt),
|
||||
{
|
||||
addSuffix: true,
|
||||
locale: ptBR,
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{!notification.read && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => markAsRead(notification.id)}
|
||||
className="h-8 px-3 gap-2"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">
|
||||
Marcar como lida
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
removeNotification(notification.id)
|
||||
}
|
||||
className="h-8 px-3 gap-2 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">
|
||||
Excluir
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{notifications.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground mb-1">
|
||||
{notifications.length}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Total</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-primary mb-1">
|
||||
{unreadCount}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Não lidas
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600 mb-1">
|
||||
{notifications.filter((n) => n.type === "success").length}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Sucesso</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-yellow-600 mb-1">
|
||||
{notifications.filter((n) => n.type === "warning").length}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Avisos</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
frontend/src/app/dashboard/candidato/page.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DashboardHeader } from "@/components/dashboard-header";
|
||||
import { StatsCard } from "@/components/stats-card";
|
||||
import { JobCard } from "@/components/job-card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { mockJobs, mockApplications, mockNotifications } from "@/lib/mock-data";
|
||||
import {
|
||||
Bell,
|
||||
FileText,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Edit,
|
||||
} from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function CandidateDashboard() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState(getCurrentUser());
|
||||
|
||||
useEffect(() => {
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser || currentUser.role !== "candidate") {
|
||||
router.push("/login");
|
||||
} else {
|
||||
setUser(currentUser);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recommendedJobs = mockJobs.slice(0, 3);
|
||||
const unreadNotifications = mockNotifications.filter((n) => !n.read);
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
Em análise
|
||||
</Badge>
|
||||
);
|
||||
case "reviewing":
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
Em análise
|
||||
</Badge>
|
||||
);
|
||||
case "interview":
|
||||
return (
|
||||
<Badge className="bg-blue-500 hover:bg-blue-600">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Entrevista
|
||||
</Badge>
|
||||
);
|
||||
case "accepted":
|
||||
return (
|
||||
<Badge className="bg-green-500 hover:bg-green-600">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Aprovado
|
||||
</Badge>
|
||||
);
|
||||
case "rejected":
|
||||
return (
|
||||
<Badge variant="destructive">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
Rejeitado
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<DashboardHeader />
|
||||
|
||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Profile Summary */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="mb-8">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold mb-2">Olá, {user.name}!</h1>
|
||||
<p className="text-muted-foreground">{user.area}</p>
|
||||
</div>
|
||||
<Button className="cursor-pointer">
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Editar Perfil
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"
|
||||
>
|
||||
<StatsCard
|
||||
title="Candidaturas"
|
||||
value={mockApplications.length}
|
||||
icon={FileText}
|
||||
description="Total de vagas aplicadas"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Em processo"
|
||||
value={
|
||||
mockApplications.filter(
|
||||
(a) => a.status === "reviewing" || a.status === "interview"
|
||||
).length
|
||||
}
|
||||
icon={Clock}
|
||||
description="Aguardando resposta"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Notificações"
|
||||
value={unreadNotifications.length}
|
||||
icon={Bell}
|
||||
description="Novas atualizações"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-1 gap-8">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-8">
|
||||
{/* Recommended Jobs */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vagas recomendadas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{recommendedJobs.map((job) => (
|
||||
<JobCard key={job.id} job={job} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Applications */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Minhas candidaturas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Vaga</TableHead>
|
||||
<TableHead>Empresa</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Data</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mockApplications.map((application) => (
|
||||
<TableRow key={application.id}>
|
||||
<TableCell className="font-medium">
|
||||
{application.jobTitle}
|
||||
</TableCell>
|
||||
<TableCell>{application.company}</TableCell>
|
||||
<TableCell>
|
||||
{getStatusBadge(application.status)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{new Date(application.appliedAt).toLocaleDateString(
|
||||
"pt-BR"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
224
frontend/src/app/dashboard/candidato/perfil/page.tsx
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { DashboardHeader } from "@/components/dashboard-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { mockUser } from "@/lib/mock-data"
|
||||
import { User, Briefcase, GraduationCap, Award, Plus, X } from "lucide-react"
|
||||
|
||||
export default function PerfilPage() {
|
||||
const [skills, setSkills] = useState(["React", "TypeScript", "Node.js", "Python"])
|
||||
const [newSkill, setNewSkill] = useState("")
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
const addSkill = () => {
|
||||
if (newSkill.trim() && !skills.includes(newSkill.trim())) {
|
||||
setSkills([...skills, newSkill.trim()])
|
||||
setNewSkill("")
|
||||
}
|
||||
}
|
||||
|
||||
const removeSkill = (skill: string) => {
|
||||
setSkills(skills.filter((s) => s !== skill))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<DashboardHeader />
|
||||
|
||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">Meu Perfil</h1>
|
||||
<p className="text-muted-foreground">Mantenha suas informações atualizadas</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Picture */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Foto de Perfil
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-6">
|
||||
<Avatar className="h-24 w-24">
|
||||
<AvatarImage src="/placeholder.svg" />
|
||||
<AvatarFallback className="text-2xl">{mockUser.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline">Alterar foto</Button>
|
||||
<p className="text-sm text-muted-foreground">JPG, PNG ou GIF. Máximo 2MB.</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Personal Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Informações Pessoais
|
||||
</CardTitle>
|
||||
<CardDescription>Suas informações básicas</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Nome completo</Label>
|
||||
<Input id="name" defaultValue={mockUser.name} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-mail</Label>
|
||||
<Input id="email" type="email" defaultValue={mockUser.email} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Telefone</Label>
|
||||
<Input id="phone" placeholder="(11) 99999-9999" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location">Localização</Label>
|
||||
<Input id="location" placeholder="São Paulo, SP" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio">Sobre mim</Label>
|
||||
<Textarea id="bio" placeholder="Conte um pouco sobre você e sua experiência profissional..." rows={4} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Professional Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Briefcase className="h-5 w-5" />
|
||||
Informações Profissionais
|
||||
</CardTitle>
|
||||
<CardDescription>Sua experiência e área de atuação</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="area">Área de atuação</Label>
|
||||
<Input id="area" defaultValue={mockUser.area} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="experience">Anos de experiência</Label>
|
||||
<Input id="experience" type="number" placeholder="5" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-role">Cargo atual</Label>
|
||||
<Input id="current-role" placeholder="Desenvolvedor Full Stack Sênior" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="linkedin">LinkedIn</Label>
|
||||
<Input id="linkedin" placeholder="https://linkedin.com/in/seu-perfil" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="portfolio">Portfolio / GitHub</Label>
|
||||
<Input id="portfolio" placeholder="https://github.com/seu-usuario" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Skills */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Award className="h-5 w-5" />
|
||||
Habilidades
|
||||
</CardTitle>
|
||||
<CardDescription>Adicione suas principais competências</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skills.map((skill) => (
|
||||
<Badge key={skill} variant="secondary" className="gap-1">
|
||||
{skill}
|
||||
<button onClick={() => removeSkill(skill)} className="ml-1 hover:text-destructive">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Adicionar habilidade..."
|
||||
value={newSkill}
|
||||
onChange={(e) => setNewSkill(e.target.value)}
|
||||
onKeyPress={(e) => e.key === "Enter" && (e.preventDefault(), addSkill())}
|
||||
/>
|
||||
<Button onClick={addSkill} variant="outline">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Education */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<GraduationCap className="h-5 w-5" />
|
||||
Formação Acadêmica
|
||||
</CardTitle>
|
||||
<CardDescription>Sua educação e certificações</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="degree">Grau de escolaridade</Label>
|
||||
<Input id="degree" placeholder="Bacharelado em Ciência da Computação" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="institution">Instituição</Label>
|
||||
<Input id="institution" placeholder="Universidade de São Paulo" />
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="start-year">Ano de início</Label>
|
||||
<Input id="start-year" type="number" placeholder="2015" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="end-year">Ano de conclusão</Label>
|
||||
<Input id="end-year" type="number" placeholder="2019" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="outline">Cancelar</Button>
|
||||
<Button onClick={handleSave} disabled={saved}>
|
||||
{saved ? "Salvo!" : "Salvar alterações"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
575
frontend/src/app/dashboard/empresa/candidaturas/page.tsx
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { DashboardHeader } from "@/components/dashboard-header";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Eye,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Calendar,
|
||||
Briefcase,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
MessageSquare,
|
||||
FileText,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function CompanyApplicationsPage() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [jobFilter, setJobFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
|
||||
const applications = [
|
||||
{
|
||||
id: "1",
|
||||
candidate: {
|
||||
id: "1",
|
||||
name: "Ana Silva",
|
||||
email: "ana.silva@email.com",
|
||||
phone: "(11) 98765-4321",
|
||||
location: "São Paulo, SP",
|
||||
avatar: "",
|
||||
experience: "5 anos",
|
||||
title: "Desenvolvedora Full Stack",
|
||||
},
|
||||
jobId: "1",
|
||||
jobTitle: "Desenvolvedor Full Stack Sênior",
|
||||
status: "pending",
|
||||
appliedAt: "2025-11-18T10:30:00",
|
||||
resumeUrl: "#",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
candidate: {
|
||||
id: "2",
|
||||
name: "Carlos Santos",
|
||||
email: "carlos.santos@email.com",
|
||||
phone: "(11) 91234-5678",
|
||||
location: "Rio de Janeiro, RJ",
|
||||
avatar: "",
|
||||
experience: "3 anos",
|
||||
title: "Designer UX/UI",
|
||||
},
|
||||
jobId: "2",
|
||||
jobTitle: "Designer UX/UI",
|
||||
status: "reviewing",
|
||||
appliedAt: "2025-11-18T09:15:00",
|
||||
resumeUrl: "#",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
candidate: {
|
||||
id: "3",
|
||||
name: "Maria Oliveira",
|
||||
email: "maria.oliveira@email.com",
|
||||
phone: "(21) 99876-5432",
|
||||
location: "Belo Horizonte, MG",
|
||||
avatar: "",
|
||||
experience: "7 anos",
|
||||
title: "Engenheira de Dados",
|
||||
},
|
||||
jobId: "3",
|
||||
jobTitle: "Product Manager",
|
||||
status: "interview",
|
||||
appliedAt: "2025-11-17T14:20:00",
|
||||
resumeUrl: "#",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
candidate: {
|
||||
id: "4",
|
||||
name: "Pedro Costa",
|
||||
email: "pedro.costa@email.com",
|
||||
phone: "(31) 98765-1234",
|
||||
location: "Curitiba, PR",
|
||||
avatar: "",
|
||||
experience: "6 anos",
|
||||
title: "Product Manager",
|
||||
},
|
||||
jobId: "1",
|
||||
jobTitle: "Desenvolvedor Full Stack Sênior",
|
||||
status: "rejected",
|
||||
appliedAt: "2025-11-16T16:45:00",
|
||||
resumeUrl: "#",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
candidate: {
|
||||
id: "5",
|
||||
name: "Juliana Ferreira",
|
||||
email: "juliana.ferreira@email.com",
|
||||
phone: "(41) 91234-8765",
|
||||
location: "Porto Alegre, RS",
|
||||
avatar: "",
|
||||
experience: "4 anos",
|
||||
title: "DevOps Engineer",
|
||||
},
|
||||
jobId: "2",
|
||||
jobTitle: "Designer UX/UI",
|
||||
status: "accepted",
|
||||
appliedAt: "2025-11-15T11:00:00",
|
||||
resumeUrl: "#",
|
||||
},
|
||||
];
|
||||
|
||||
const statusConfig = {
|
||||
pending: {
|
||||
label: "Pendente",
|
||||
color: "bg-yellow-500",
|
||||
variant: "secondary" as const,
|
||||
},
|
||||
reviewing: {
|
||||
label: "Em Análise",
|
||||
color: "bg-blue-500",
|
||||
variant: "default" as const,
|
||||
},
|
||||
interview: {
|
||||
label: "Entrevista",
|
||||
color: "bg-purple-500",
|
||||
variant: "default" as const,
|
||||
},
|
||||
accepted: {
|
||||
label: "Aceito",
|
||||
color: "bg-green-500",
|
||||
variant: "default" as const,
|
||||
},
|
||||
rejected: {
|
||||
label: "Rejeitado",
|
||||
color: "bg-red-500",
|
||||
variant: "destructive" as const,
|
||||
},
|
||||
};
|
||||
|
||||
const filteredApplications = applications.filter((app) => {
|
||||
const matchesSearch =
|
||||
app.candidate.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
app.jobTitle.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesJob = jobFilter === "all" || app.jobId === jobFilter;
|
||||
const matchesStatus = statusFilter === "all" || app.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesJob && matchesStatus;
|
||||
});
|
||||
|
||||
const groupedByStatus = {
|
||||
pending: filteredApplications.filter((a) => a.status === "pending"),
|
||||
reviewing: filteredApplications.filter((a) => a.status === "reviewing"),
|
||||
interview: filteredApplications.filter((a) => a.status === "interview"),
|
||||
accepted: filteredApplications.filter((a) => a.status === "accepted"),
|
||||
rejected: filteredApplications.filter((a) => a.status === "rejected"),
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffHours < 1) return "Agora há pouco";
|
||||
if (diffHours < 24) return `Há ${diffHours}h`;
|
||||
if (diffDays === 1) return "Ontem";
|
||||
if (diffDays < 7) return `Há ${diffDays} dias`;
|
||||
return date.toLocaleDateString("pt-BR");
|
||||
};
|
||||
|
||||
const ApplicationCard = ({ app }: { app: (typeof applications)[0] }) => (
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||
<Avatar className="h-12 w-12 shrink-0">
|
||||
<AvatarImage src={app.candidate.avatar} />
|
||||
<AvatarFallback className="bg-primary/10 text-primary">
|
||||
{app.candidate.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-base sm:text-lg truncate">
|
||||
{app.candidate.name}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{app.candidate.title}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
statusConfig[app.status as keyof typeof statusConfig]
|
||||
.variant
|
||||
}
|
||||
className="shrink-0"
|
||||
>
|
||||
{statusConfig[app.status as keyof typeof statusConfig].label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-sm text-muted-foreground mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Briefcase className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{app.jobTitle}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{app.candidate.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4 shrink-0" />
|
||||
<span className="whitespace-nowrap">
|
||||
{formatDate(app.appliedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4 shrink-0" />
|
||||
<span className="whitespace-nowrap">
|
||||
{app.candidate.experience}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
Ver Perfil
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Perfil do Candidato</DialogTitle>
|
||||
<DialogDescription>
|
||||
Informações detalhadas sobre {app.candidate.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Avatar className="h-20 w-20">
|
||||
<AvatarImage src={app.candidate.avatar} />
|
||||
<AvatarFallback className="bg-primary/10 text-primary text-xl">
|
||||
{app.candidate.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-xl">
|
||||
{app.candidate.name}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{app.candidate.title}
|
||||
</p>
|
||||
<Badge
|
||||
className="mt-2"
|
||||
variant={
|
||||
statusConfig[
|
||||
app.status as keyof typeof statusConfig
|
||||
].variant
|
||||
}
|
||||
>
|
||||
{
|
||||
statusConfig[
|
||||
app.status as keyof typeof statusConfig
|
||||
].label
|
||||
}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-4 border-t">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Email
|
||||
</Label>
|
||||
<p className="text-sm font-medium">
|
||||
{app.candidate.email}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Telefone
|
||||
</Label>
|
||||
<p className="text-sm font-medium">
|
||||
{app.candidate.phone}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Localização
|
||||
</Label>
|
||||
<p className="text-sm font-medium">
|
||||
{app.candidate.location}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Experiência
|
||||
</Label>
|
||||
<p className="text-sm font-medium">
|
||||
{app.candidate.experience}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<Label className="text-xs text-muted-foreground mb-2 block">
|
||||
Vaga aplicada
|
||||
</Label>
|
||||
<p className="text-sm font-medium">{app.jobTitle}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Candidatura enviada em {formatDate(app.appliedAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-4">
|
||||
<Button className="flex-1" asChild>
|
||||
<a href={`mailto:${app.candidate.email}`}>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Enviar Email
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" className="flex-1" asChild>
|
||||
<a
|
||||
href={app.resumeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Ver Currículo
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<a href={`mailto:${app.candidate.email}`}>
|
||||
<Mail className="h-4 w-4 mr-1" />
|
||||
Contato
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
{app.status === "pending" && (
|
||||
<>
|
||||
<Button size="sm" variant="default">
|
||||
<CheckCircle2 className="h-4 w-4 mr-1" />
|
||||
Aprovar
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive">
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
Rejeitar
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<DashboardHeader />
|
||||
|
||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-foreground mb-2">
|
||||
Candidaturas
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gerencie todas as candidaturas recebidas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{applications.length}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-yellow-600">
|
||||
{groupedByStatus.pending.length}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Pendentes</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{groupedByStatus.reviewing.length}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Em Análise</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{groupedByStatus.interview.length}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Entrevistas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{groupedByStatus.accepted.length}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Aceitos</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="relative sm:col-span-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar candidato ou vaga..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={jobFilter} onValueChange={setJobFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Filtrar por vaga" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas as vagas</SelectItem>
|
||||
<SelectItem value="1">
|
||||
Desenvolvedor Full Stack Sênior
|
||||
</SelectItem>
|
||||
<SelectItem value="2">Designer UX/UI</SelectItem>
|
||||
<SelectItem value="3">Product Manager</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Filtrar por status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos os status</SelectItem>
|
||||
<SelectItem value="pending">Pendente</SelectItem>
|
||||
<SelectItem value="reviewing">Em Análise</SelectItem>
|
||||
<SelectItem value="interview">Entrevista</SelectItem>
|
||||
<SelectItem value="accepted">Aceito</SelectItem>
|
||||
<SelectItem value="rejected">Rejeitado</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Applications by Status */}
|
||||
<Tabs defaultValue="all" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-3 sm:grid-cols-6">
|
||||
<TabsTrigger value="all">Todas</TabsTrigger>
|
||||
<TabsTrigger value="pending">Pendentes</TabsTrigger>
|
||||
<TabsTrigger value="reviewing">Em Análise</TabsTrigger>
|
||||
<TabsTrigger value="interview">Entrevista</TabsTrigger>
|
||||
<TabsTrigger value="accepted">Aceitos</TabsTrigger>
|
||||
<TabsTrigger value="rejected">Rejeitados</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="all" className="space-y-4">
|
||||
{filteredApplications.map((app) => (
|
||||
<ApplicationCard key={app.id} app={app} />
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
{Object.entries(groupedByStatus).map(([status, apps]) => (
|
||||
<TabsContent key={status} value={status} className="space-y-4">
|
||||
{apps.length > 0 ? (
|
||||
apps.map((app) => <ApplicationCard key={app.id} app={app} />)
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<MessageSquare className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
Nenhuma candidatura com status "
|
||||
{
|
||||
statusConfig[status as keyof typeof statusConfig]
|
||||
.label
|
||||
}
|
||||
"
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <label className={className}>{children}</label>;
|
||||
}
|
||||
434
frontend/src/app/dashboard/empresa/mensagens/page.tsx
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { DashboardHeader } from "@/components/dashboard-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Search,
|
||||
Send,
|
||||
Paperclip,
|
||||
MoreVertical,
|
||||
Star,
|
||||
Archive,
|
||||
Trash2,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
sender: "company" | "candidate";
|
||||
}
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
candidateId: string;
|
||||
candidateName: string;
|
||||
candidateAvatar: string;
|
||||
lastMessage: string;
|
||||
lastMessageTime: string;
|
||||
unread: number;
|
||||
jobTitle: string;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export default function CompanyMessagesPage() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedConversation, setSelectedConversation] = useState<
|
||||
string | null
|
||||
>("1");
|
||||
const [newMessage, setNewMessage] = useState("");
|
||||
const [showMobileList, setShowMobileList] = useState(true);
|
||||
|
||||
const conversations: Conversation[] = [
|
||||
{
|
||||
id: "1",
|
||||
candidateId: "1",
|
||||
candidateName: "Ana Silva",
|
||||
candidateAvatar: "",
|
||||
lastMessage: "Obrigada pela oportunidade! Quando podemos agendar?",
|
||||
lastMessageTime: "2025-11-18T10:30:00",
|
||||
unread: 2,
|
||||
jobTitle: "Desenvolvedor Full Stack Sênior",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
content:
|
||||
"Olá Ana! Parabéns, você foi selecionada para a próxima fase do processo seletivo.",
|
||||
timestamp: "2025-11-18T09:00:00",
|
||||
sender: "company",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
content: "Que ótima notícia! Muito obrigada pela oportunidade.",
|
||||
timestamp: "2025-11-18T09:15:00",
|
||||
sender: "candidate",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
content:
|
||||
"Gostaríamos de agendar uma entrevista técnica. Você tem disponibilidade esta semana?",
|
||||
timestamp: "2025-11-18T10:00:00",
|
||||
sender: "company",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
content: "Obrigada pela oportunidade! Quando podemos agendar?",
|
||||
timestamp: "2025-11-18T10:30:00",
|
||||
sender: "candidate",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
candidateId: "2",
|
||||
candidateName: "Carlos Santos",
|
||||
candidateAvatar: "",
|
||||
lastMessage: "Sim, posso fazer a entrevista quinta-feira às 14h",
|
||||
lastMessageTime: "2025-11-17T16:20:00",
|
||||
unread: 0,
|
||||
jobTitle: "Designer UX/UI",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
content: "Olá Carlos! Gostamos muito do seu portfólio.",
|
||||
timestamp: "2025-11-17T14:00:00",
|
||||
sender: "company",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
content: "Muito obrigado! Fico feliz em saber.",
|
||||
timestamp: "2025-11-17T14:30:00",
|
||||
sender: "candidate",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
content: "Podemos agendar uma call para quinta-feira?",
|
||||
timestamp: "2025-11-17T15:00:00",
|
||||
sender: "company",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
content: "Sim, posso fazer a entrevista quinta-feira às 14h",
|
||||
timestamp: "2025-11-17T16:20:00",
|
||||
sender: "candidate",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
candidateId: "3",
|
||||
candidateName: "Maria Oliveira",
|
||||
candidateAvatar: "",
|
||||
lastMessage: "Perfeito! Estarei preparada para a entrevista.",
|
||||
lastMessageTime: "2025-11-16T11:45:00",
|
||||
unread: 0,
|
||||
jobTitle: "Product Manager",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
content: "Olá Maria! Seu perfil chamou nossa atenção.",
|
||||
timestamp: "2025-11-16T10:00:00",
|
||||
sender: "company",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
content: "Obrigada! Estou muito interessada na vaga.",
|
||||
timestamp: "2025-11-16T10:30:00",
|
||||
sender: "candidate",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
content: "Ótimo! Vamos agendar uma entrevista para amanhã às 15h?",
|
||||
timestamp: "2025-11-16T11:00:00",
|
||||
sender: "company",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
content: "Perfeito! Estarei preparada para a entrevista.",
|
||||
timestamp: "2025-11-16T11:45:00",
|
||||
sender: "candidate",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const filteredConversations = conversations.filter(
|
||||
(conv) =>
|
||||
conv.candidateName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
conv.jobTitle.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const activeConversation = conversations.find(
|
||||
(c) => c.id === selectedConversation
|
||||
);
|
||||
|
||||
const handleSendMessage = () => {
|
||||
if (newMessage.trim()) {
|
||||
// Aqui você adicionaria a lógica para enviar a mensagem
|
||||
console.log("Enviando mensagem:", newMessage);
|
||||
setNewMessage("");
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffHours < 1) return "Agora";
|
||||
if (diffHours < 24) return `${diffHours}h atrás`;
|
||||
if (diffDays === 1) return "Ontem";
|
||||
if (diffDays < 7) return `${diffDays}d atrás`;
|
||||
return date.toLocaleDateString("pt-BR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const formatMessageTime = (timestamp: string) => {
|
||||
return new Date(timestamp).toLocaleTimeString("pt-BR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<DashboardHeader />
|
||||
|
||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-foreground mb-2">
|
||||
Mensagens
|
||||
</h1>
|
||||
<p className="text-muted-foreground">Converse com os candidatos</p>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className="grid lg:grid-cols-[350px_1fr] h-[calc(100vh-250px)] min-h-[500px]">
|
||||
{/* Conversations List */}
|
||||
<div className={`border-r bg-muted/10 ${showMobileList ? 'block' : 'hidden lg:block'}`}>
|
||||
<div className="p-4 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar conversas..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[calc(100%-73px)]">
|
||||
<div className="p-2">
|
||||
{filteredConversations.map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => {
|
||||
setSelectedConversation(conv.id)
|
||||
setShowMobileList(false)
|
||||
}}
|
||||
className={`w-full p-3 rounded-lg text-left hover:bg-muted/50 transition-colors mb-1 ${
|
||||
selectedConversation === conv.id ? "bg-muted" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="relative">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarImage src={conv.candidateAvatar} />
|
||||
<AvatarFallback className="bg-primary/10 text-primary">
|
||||
{conv.candidateName
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{conv.unread > 0 && (
|
||||
<span className="absolute -top-1 -right-1 h-5 w-5 bg-primary text-primary-foreground text-xs rounded-full flex items-center justify-center">
|
||||
{conv.unread}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<h3 className="font-semibold text-sm truncate">
|
||||
{conv.candidateName}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatTime(conv.lastMessageTime)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate mb-1">
|
||||
{conv.jobTitle}
|
||||
</p>
|
||||
<p
|
||||
className={`text-sm truncate ${
|
||||
conv.unread > 0
|
||||
? "font-medium"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{conv.lastMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
{activeConversation ? (
|
||||
<div className={`flex flex-col ${!showMobileList ? 'block' : 'hidden lg:flex'}`}>
|
||||
{/* Chat Header */}
|
||||
<div className="p-4 border-b flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden shrink-0"
|
||||
onClick={() => setShowMobileList(true)}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<Avatar className="h-10 w-10 shrink-0">
|
||||
<AvatarImage src={activeConversation.candidateAvatar} />
|
||||
<AvatarFallback className="bg-primary/10 text-primary">
|
||||
{activeConversation.candidateName
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate">
|
||||
{activeConversation.candidateName}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{activeConversation.jobTitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Star className="h-4 w-4 mr-2" />
|
||||
Favoritar
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
Arquivar
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Excluir
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-4">
|
||||
{activeConversation.messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${
|
||||
message.sender === "company"
|
||||
? "justify-end"
|
||||
: "justify-start"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[70%] rounded-lg p-3 ${
|
||||
message.sender === "company"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm">{message.content}</p>
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
message.sender === "company"
|
||||
? "text-primary-foreground/70"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{formatMessageTime(message.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Message Input */}
|
||||
<div className="p-3 sm:p-4 border-t">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="icon" className="shrink-0 hidden sm:flex">
|
||||
<Paperclip className="h-5 w-5" />
|
||||
</Button>
|
||||
<Input
|
||||
placeholder="Digite sua mensagem..."
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
onKeyPress={(e) =>
|
||||
e.key === "Enter" && handleSendMessage()
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Send className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`flex items-center justify-center h-full ${!showMobileList ? 'flex' : 'hidden lg:flex'}`}>
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>Selecione uma conversa para começar</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
249
frontend/src/app/dashboard/empresa/notificacoes/page.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
"use client"
|
||||
|
||||
import { DashboardHeader } from "@/components/dashboard-header"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
Bell,
|
||||
Check,
|
||||
CheckCheck,
|
||||
Trash2,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Info,
|
||||
AlertTriangle,
|
||||
Archive
|
||||
} from "lucide-react"
|
||||
import { useNotifications } from "@/contexts/notification-context"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
export default function CompanyNotificationsPage() {
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
removeNotification,
|
||||
clearAllNotifications,
|
||||
} = useNotifications()
|
||||
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />
|
||||
case 'error':
|
||||
return <AlertCircle className="h-5 w-5 text-red-500" />
|
||||
case 'warning':
|
||||
return <AlertTriangle className="h-5 w-5 text-yellow-500" />
|
||||
default:
|
||||
return <Info className="h-5 w-5 text-blue-500" />
|
||||
}
|
||||
}
|
||||
|
||||
const getNotificationBgColor = (type: string, read: boolean) => {
|
||||
if (read) return 'bg-muted/50'
|
||||
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900'
|
||||
case 'error':
|
||||
return 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-900'
|
||||
case 'warning':
|
||||
return 'bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-900'
|
||||
default:
|
||||
return 'bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-900'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<DashboardHeader />
|
||||
|
||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-foreground mb-2">
|
||||
Notificações
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{unreadCount > 0 ? (
|
||||
<>Você tem <span className="font-semibold text-foreground">{unreadCount}</span> {unreadCount === 1 ? 'notificação não lida' : 'notificações não lidas'}</>
|
||||
) : (
|
||||
'Você está em dia com suas notificações'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{notifications.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={markAllAsRead}
|
||||
className="gap-2"
|
||||
>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
Marcar todas como lidas
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearAllNotifications}
|
||||
className="gap-2 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Limpar todas
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notifications List */}
|
||||
{notifications.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Bell className="h-16 w-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-semibold mb-2">Nenhuma notificação</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Quando você receber notificações, elas aparecerão aqui
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{notifications.map((notification, index) => (
|
||||
<motion.div
|
||||
key={notification.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<Card
|
||||
className={`relative overflow-hidden transition-all hover:shadow-md ${
|
||||
getNotificationBgColor(notification.type, notification.read)
|
||||
}`}
|
||||
>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Icon */}
|
||||
<div className="shrink-0 mt-1">
|
||||
{getNotificationIcon(notification.type)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={`font-semibold text-base mb-1 ${
|
||||
notification.read ? 'text-muted-foreground' : 'text-foreground'
|
||||
}`}>
|
||||
{notification.title}
|
||||
</h3>
|
||||
<p className={`text-sm leading-relaxed ${
|
||||
notification.read ? 'text-muted-foreground' : 'text-foreground/80'
|
||||
}`}>
|
||||
{notification.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Unread indicator */}
|
||||
{!notification.read && (
|
||||
<div className="shrink-0">
|
||||
<div className="h-2 w-2 bg-primary rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between gap-4 pt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(notification.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: ptBR
|
||||
})}
|
||||
</span>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{!notification.read && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => markAsRead(notification.id)}
|
||||
className="h-8 px-3 gap-2"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Marcar como lida</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeNotification(notification.id)}
|
||||
className="h-8 px-3 gap-2 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Excluir</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{notifications.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground mb-1">
|
||||
{notifications.length}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Total</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-primary mb-1">
|
||||
{unreadCount}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Não lidas</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600 mb-1">
|
||||
{notifications.filter(n => n.type === 'success').length}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Sucesso</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-yellow-600 mb-1">
|
||||
{notifications.filter(n => n.type === 'warning').length}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Avisos</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||