first commit

This commit is contained in:
Tiago Yamamoto 2025-12-09 19:04:48 -03:00
commit 1c7ef95c1a
829 changed files with 113420 additions and 0 deletions

78
README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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)
}
}

View 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
View 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
View 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
View 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
View 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
View 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=

View 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"))
}

View 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)
})
}

View 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)
})
}

View 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()
}

View 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"`
}

View 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
}

View 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"`
}

View 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"`
}

View 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
}

View 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)
}

View 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
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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
View 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
View 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"`
}

View 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)
}

View 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)
}

View 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")
}

View file

@ -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
}

View file

@ -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
}

View 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)
})
}
}

View 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)
})
}

View 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)
}

View 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"`
}

View 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"`
}

View 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
View 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"`
}

View 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"`
}

View 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
View 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,
}
}

View 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)
}

View 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
}

View 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)
}

View 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
View 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
}

View 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
}

View 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';

View 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';

View 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';

View 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)';

View 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';

View 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';

View 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';

View 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';

View 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)
);

View 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;

View 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
View 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
View file

@ -0,0 +1 @@
legacy-peer-deps=true

48
frontend/README.md Normal file
View 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
View 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": {}
}

View 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
View 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

File diff suppressed because it is too large Load diff

75
frontend/package.json Normal file
View 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"
}
}

View file

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

BIN
frontend/public/logo_tj.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View 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">
tem uma conta?{" "}
<Link href="/login" className="text-primary hover:underline font-medium">
Faça login
</Link>
</p>
</div>
</div>
</div>
</div>
);
}

View 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">
tem uma conta?{" "}
<Link href="/login" className="text-primary hover:underline font-medium">
Faça login
</Link>
</p>
</div>
</div>
</div>
</div>
);
}

View 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
esteja respondida .
</p>
<Button variant="outline" className="w-full cursor-pointer bg-transparent">
Ver FAQ
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
</section>
</main>
<Footer />
</div>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
)
}

View 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 `${diffHours}h`;
if (diffDays === 1) return "Ontem";
if (diffDays < 7) return `${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>;
}

View 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>
);
}

View 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>
)
}

Some files were not shown because too many files have changed in this diff Show more