Merge pull request #41 from rede5/task5

feat: melhorias na gestão de empresas e correções de persistência
Backend: Correção na persistência do email da empresa (CreateCompanyUseCase) e suporte a exclusão em cascata (Cascade Delete) para evitar erro 500.
Backend: Adicionado suporte completo para Phone, Website, Address, Description e Slug na criação.
Backend: Correção crítica no JobService para ocultar nome de candidatos na listagem de vagas.
Frontend: Adição da coluna 'Email' na listagem de empresas e padronização dos ícones de ação.
Frontend: Inclusão de novas chaves de tradução (i18n) e melhorias no modal de criação.
This commit is contained in:
Andre F. Rodrigues 2026-01-08 17:17:20 -03:00 committed by GitHub
commit a35f06c5de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 700 additions and 209 deletions

View file

@ -178,6 +178,10 @@ func (h *CoreHandlers) CreateCompany(w http.ResponseWriter, r *http.Request) {
resp, err := h.createCompanyUC.Execute(r.Context(), req)
if err != nil {
if strings.Contains(err.Error(), "already exists") {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

View file

@ -4,13 +4,19 @@ 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,omitempty"`
Status string `json:"status"` // "ACTIVE", "INACTIVE"
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Document *string `json:"document,omitempty"` // CNPJ, EIN, VAT
Contact *string `json:"contact,omitempty"` // Email
Phone *string `json:"phone,omitempty"`
Website *string `json:"website,omitempty"`
Address *string `json:"address,omitempty"`
Description *string `json:"description,omitempty"`
Type string `json:"type"` // "COMPANY", "CANDIDATE_WORKSPACE"
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.
@ -18,6 +24,8 @@ func NewCompany(id, name string, document, contact *string) *Company {
return &Company{
ID: id,
Name: name,
Slug: name, // Basic slug, repo might refine
Type: "COMPANY",
Document: document,
Contact: contact,
Status: "ACTIVE",

View file

@ -12,6 +12,7 @@ type CreateCompanyRequest struct {
Password string `json:"password"`
Phone string `json:"phone"`
Website *string `json:"website,omitempty"`
Address *string `json:"address,omitempty"`
EmployeeCount *string `json:"employeeCount,omitempty"`
FoundedYear *int `json:"foundedYear,omitempty"`
Description *string `json:"description,omitempty"`

View file

@ -51,6 +51,7 @@ func (uc *RegisterCandidateUseCase) Execute(ctx context.Context, input dto.Regis
nil, // No document for candidates
nil, // No contact - will use user's contact info
)
candidateCompany.Type = "CANDIDATE_WORKSPACE"
savedCompany, err := uc.companyRepo.Save(ctx, candidateCompany)
if err != nil {

View file

@ -2,6 +2,7 @@ package tenant
import (
"context"
"fmt"
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
@ -37,22 +38,57 @@ func (uc *CreateCompanyUseCase) Execute(ctx context.Context, input dto.CreateCom
// 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.
// 0. Ensure AdminEmail is set (fallback to Email)
if input.AdminEmail == "" {
input.AdminEmail = input.Email
}
if input.Contact == "" && input.Email != "" {
input.Contact = input.Email
}
// 1. Check if user already exists
existingUser, _ := uc.userRepo.FindByEmail(ctx, input.AdminEmail)
if existingUser != nil {
return nil, fmt.Errorf("user with email %s already exists", input.AdminEmail)
}
company := entity.NewCompany("", input.Name, &input.Document, &input.Contact)
// Map optional fields
if input.Phone != "" {
company.Phone = &input.Phone
}
if input.Website != nil {
company.Website = input.Website
}
if input.Description != nil {
company.Description = input.Description
}
if input.Address != nil {
company.Address = input.Address
}
// Address isn't in DTO explicitly but maybe part of inputs?
// Checking DTO: it has no Address field.
// I will check DTO again. Step 2497 showed Name, CompanyName, Document, Contact, AdminEmail, Email, Password, Phone, Website, EmployeeCount, FoundedYear, Description.
// It misses Address.
// I will skip Address mapping for now or add it to DTO if user wants it.
// But let's map what we have.
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!")
pwd := input.Password
if pwd == "" {
pwd = "ChangeMe123!"
}
hashedPassword, _ := uc.authService.HashPassword(pwd)
adminUser := entity.NewUser("", savedCompany.ID, "Admin", input.AdminEmail)
adminUser.PasswordHash = hashedPassword
adminUser.AssignRole(entity.Role{Name: "ADMIN"})
adminUser.AssignRole(entity.Role{Name: entity.RoleAdmin})
_, err = uc.userRepo.Save(ctx, adminUser)
if err != nil {

View file

@ -70,4 +70,5 @@ type User struct {
AvatarUrl *string `json:"avatarUrl,omitempty"`
CreatedAt time.Time `json:"createdAt"`
CompanyID *string `json:"companyId,omitempty"`
Roles []string `json:"roles,omitempty"`
}

View file

@ -20,21 +20,30 @@ func NewCompanyRepository(db *sql.DB) *CompanyRepository {
func (r *CompanyRepository) Save(ctx context.Context, company *entity.Company) (*entity.Company, error) {
// companies table uses UUID id, DB generates it
query := `
INSERT INTO companies (name, slug, type, document, email, description, verified, active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
INSERT INTO companies (name, slug, type, document, email, phone, website, address, description, verified, active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id
`
slug := company.Name // TODO: slugify function
slug := company.Slug
// Fallback slug generation if empty
if slug == "" {
slug = company.Name
}
// TODO: better slugify logic in service/entity
var id string
err := r.db.QueryRowContext(ctx, query,
company.Name,
slug,
"company",
company.Type,
company.Document,
company.Contact, // mapped to email
"{}", // description as JSON
true, // verified
company.Contact, // email
company.Phone,
company.Website,
company.Address,
company.Description,
true, // verified
company.Status == "ACTIVE",
company.CreatedAt,
company.UpdatedAt,

View file

@ -25,10 +25,11 @@ func (s *AdminService) ListCompanies(ctx context.Context, verified *bool, page,
offset := (page - 1) * limit
// Count Total
countQuery := `SELECT COUNT(*) FROM companies`
// Count Total
countQuery := `SELECT COUNT(*) FROM companies WHERE type != 'CANDIDATE_WORKSPACE'`
var countArgs []interface{}
if verified != nil {
countQuery += " WHERE verified = $1"
countQuery += " AND verified = $1"
countArgs = append(countArgs, *verified)
}
var total int
@ -40,11 +41,12 @@ func (s *AdminService) ListCompanies(ctx context.Context, verified *bool, page,
baseQuery := `
SELECT id, name, slug, type, document, address, region_id, city_id, phone, email, website, logo_url, description, active, verified, created_at, updated_at
FROM companies
WHERE type != 'CANDIDATE_WORKSPACE'
`
var args []interface{}
if verified != nil {
baseQuery += " WHERE verified = $1"
baseQuery += " AND verified = $1"
args = append(args, *verified)
}
@ -610,9 +612,43 @@ func (s *AdminService) GetUser(ctx context.Context, id string) (*dto.User, error
if avatarURL.Valid {
u.AvatarUrl = &avatarURL.String
}
// Fetch roles
roles, err := s.getUserRoles(ctx, u.ID)
if err == nil {
u.Roles = roles
}
return &u, nil
}
func (s *AdminService) getUserRoles(ctx context.Context, userID string) ([]string, error) {
query := `
SELECT role FROM user_roles WHERE user_id = $1
UNION
SELECT role FROM users WHERE id = $1 AND role IS NOT NULL AND role != ''
`
rows, err := s.DB.QueryContext(ctx, query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var roles []string
seen := make(map[string]bool)
for rows.Next() {
var roleName string
if err := rows.Scan(&roleName); err == nil {
if !seen[roleName] {
roles = append(roles, roleName)
seen[roleName] = true
}
}
}
return roles, nil
}
// GetCompanyByUserID fetches the company associated with a user
func (s *AdminService) GetCompanyByUserID(ctx context.Context, userID string) (*models.Company, error) {
// First, try to find company where this user is admin
@ -728,9 +764,28 @@ func (s *AdminService) DeleteCompany(ctx context.Context, id string) error {
return err
}
// Delete
_, err = s.DB.ExecContext(ctx, `DELETE FROM companies WHERE id=$1`, id)
return err
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Delete jobs
if _, err := tx.ExecContext(ctx, `DELETE FROM jobs WHERE company_id=$1`, id); err != nil {
return err
}
// Delete users
if _, err := tx.ExecContext(ctx, `DELETE FROM users WHERE tenant_id=$1`, id); err != nil {
return err
}
// Delete company
if _, err := tx.ExecContext(ctx, `DELETE FROM companies WHERE id=$1`, id); err != nil {
return err
}
return tx.Commit()
}
// ============================================================================

View file

@ -79,7 +79,10 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany
SELECT
j.id, j.company_id, j.title, j.description, j.salary_min, j.salary_max, j.salary_type,
j.employment_type, j.work_mode, j.working_hours, j.location, j.status, j.salary_negotiable, j.is_featured, j.created_at, j.updated_at,
COALESCE(c.name, '') as company_name, c.logo_url as company_logo_url,
CASE
WHEN c.type = 'CANDIDATE_WORKSPACE' OR c.name LIKE 'Candidate - %' THEN ''
ELSE COALESCE(c.name, '')
END as company_name, c.logo_url as company_logo_url,
r.name as region_name, ci.name as city_name,
(SELECT COUNT(*) FROM applications a WHERE a.job_id = j.id) as applications_count
FROM jobs j

View file

@ -4,6 +4,7 @@ import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
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 { Badge } from "@/components/ui/badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@ -17,12 +18,13 @@ import {
DialogTrigger,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Plus, Search, Loader2, RefreshCw, Building2, CheckCircle, XCircle, Eye, Trash2, Pencil } from "lucide-react"
import { Plus, Search, Loader2, RefreshCw, Building2, CheckCircle, XCircle, Eye, EyeOff, Trash2, Pencil } from "lucide-react"
import { Switch } from "@/components/ui/switch"
import { adminCompaniesApi, type AdminCompany } from "@/lib/api"
import { getCurrentUser, isAdminUser } from "@/lib/auth"
import { toast } from "sonner"
import { Skeleton } from "@/components/ui/skeleton"
import { useTranslation } from "@/lib/i18n"
const companyDateFormatter = new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
@ -56,7 +58,28 @@ const formatDescription = (description: string | undefined) => {
return <p className="text-sm mt-1">{description}</p>
}
// Format CNPJ: 00.000.000/0000-00
const formatCNPJ = (value: string) => {
return value
.replace(/\D/g, "")
.replace(/^(\d{2})(\d)/, "$1.$2")
.replace(/^(\d{2})\.(\d{3})(\d)/, "$1.$2.$3")
.replace(/\.(\d{3})(\d)/, ".$1/$2")
.replace(/(\d{4})(\d)/, "$1-$2")
.substring(0, 18)
}
// Format Phone: (00) 00000-0000
const formatPhone = (value: string) => {
return value
.replace(/\D/g, "")
.replace(/^(\d{2})(\d)/, "($1) $2")
.replace(/(\d{5})(\d)/, "$1-$2")
.substring(0, 15)
}
export default function AdminCompaniesPage() {
const { t } = useTranslation()
const router = useRouter()
const [companies, setCompanies] = useState<AdminCompany[]>([])
const [loading, setLoading] = useState(true)
@ -69,10 +92,19 @@ export default function AdminCompaniesPage() {
const [creating, setCreating] = useState(false)
const [updating, setUpdating] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [formData, setFormData] = useState({
name: "",
slug: "",
email: "",
password: "",
confirmPassword: "",
document: "",
phone: "",
website: "",
address: "",
description: "",
})
const [editFormData, setEditFormData] = useState({
name: "",
@ -121,14 +153,35 @@ export default function AdminCompaniesPage() {
const handleCreate = async () => {
try {
setCreating(true)
await adminCompaniesApi.create(formData)
toast.success("Company created successfully!")
// Strip non-digits for payload
const payload = {
...formData,
document: formData.document.replace(/\D/g, ''),
phone: formData.phone.replace(/\D/g, ''),
}
await adminCompaniesApi.create(payload)
toast.success(t('admin.companies.success.created'))
setIsDialogOpen(false)
setFormData({ name: "", slug: "", email: "" })
setFormData({
name: "",
slug: "",
email: "",
password: "",
confirmPassword: "",
document: "",
phone: "",
website: "",
address: "",
description: "",
})
loadCompanies(1) // Reload first page
} catch (error) {
} catch (error: any) {
console.error("Error creating company:", error)
toast.error("Failed to create company")
if (error.message?.includes("already exists")) {
toast.error(t('admin.companies.error.emailExists', { defaultValue: "User with this email already exists" }))
} else {
toast.error("Failed to create company")
}
} finally {
setCreating(false)
}
@ -147,7 +200,7 @@ export default function AdminCompaniesPage() {
try {
await adminCompaniesApi.updateStatus(company.id, { [field]: newValue })
toast.success(`Company ${field} updated`)
toast.success(t('admin.companies.success.statusUpdated', { field }))
} catch (error) {
toast.error(`Failed to update ${field}`)
setCompanies(originalCompanies)
@ -164,11 +217,11 @@ export default function AdminCompaniesPage() {
}
const handleDelete = async (company: AdminCompany) => {
if (!window.confirm(`Are you sure you want to delete ${company.name}? This action cannot be undone.`)) return
if (!window.confirm(t('admin.companies.deleteConfirm', { name: company.name }))) return
try {
await adminCompaniesApi.delete(company.id)
toast.success("Company deleted successfully")
toast.success(t('admin.companies.success.deleted'))
setIsViewDialogOpen(false)
loadCompanies()
} catch (error) {
@ -218,7 +271,7 @@ export default function AdminCompaniesPage() {
description: editFormData.description,
})
toast.success("Company updated successfully")
toast.success(t('admin.companies.success.updated'))
setIsEditDialogOpen(false)
loadCompanies()
} catch (error) {
@ -240,29 +293,29 @@ export default function AdminCompaniesPage() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Company management</h1>
<p className="text-muted-foreground mt-1">Manage all registered companies</p>
<h1 className="text-3xl font-bold text-foreground">{t('admin.companies.title')}</h1>
<p className="text-muted-foreground mt-1">{t('admin.companies.subtitle')}</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => loadCompanies()} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
Refresh
{t('admin.companies.refresh')}
</Button>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="h-4 w-4" />
New company
{t('admin.companies.newCompany')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create new company</DialogTitle>
<DialogDescription>Fill in the company details</DialogDescription>
<DialogTitle>{t('admin.companies.create.title')}</DialogTitle>
<DialogDescription>{t('admin.companies.create.subtitle')}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Company name</Label>
<Label htmlFor="name">{t('admin.companies.create.name')}</Label>
<Input
id="name"
value={formData.name}
@ -273,34 +326,134 @@ export default function AdminCompaniesPage() {
slug: generateSlug(e.target.value),
})
}
placeholder="Company XYZ"
placeholder={t('admin.companies.create.namePlaceholder')}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="slug">Slug (URL)</Label>
<Label htmlFor="slug">{t('admin.companies.create.slug')}</Label>
<Input
id="slug"
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
placeholder="empresa-xyz"
placeholder={t('admin.companies.create.slugPlaceholder')}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Label htmlFor="document">{t('admin.companies.fields.document')}</Label>
<Input
id="document"
maxLength={18}
value={formData.document}
onChange={(e) => setFormData({ ...formData, document: formatCNPJ(e.target.value) })}
placeholder="CNPJ / Document"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="email">{t('admin.companies.create.email')}</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="hello@company.com"
placeholder={t('admin.companies.create.emailPlaceholder')}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">{t('admin.companies.fields.password')}</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="******"
/>
<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>
</div>
<div className="grid gap-2">
<Label htmlFor="confirmPassword">{t('admin.companies.fields.confirmPassword')}</Label>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
placeholder="******"
/>
<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>
{formData.password !== formData.confirmPassword && formData.confirmPassword && (
<p className="text-xs text-red-500">{t('admin.companies.fields.passwordsDoNotMatch')}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="phone">{t('admin.companies.fields.phone')}</Label>
<Input
id="phone"
maxLength={15}
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: formatPhone(e.target.value) })}
placeholder="+55 11 99999-9999"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="website">{t('admin.companies.fields.website')}</Label>
<Input
id="website"
value={formData.website}
onChange={(e) => setFormData({ ...formData, website: e.target.value })}
placeholder="https://..."
/>
</div>
<div className="grid gap-2">
<Label htmlFor="address">{t('admin.companies.fields.address')}</Label>
<Input
id="address"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
placeholder="Address..."
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">{t('admin.companies.fields.description')}</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Company description..."
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>Cancel</Button>
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>{t('admin.companies.create.cancel')}</Button>
<Button onClick={handleCreate} disabled={creating}>
{creating && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Create company
{t('admin.companies.create.submit')}
</Button>
</DialogFooter>
</DialogContent>
@ -312,25 +465,25 @@ export default function AdminCompaniesPage() {
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-3">
<CardDescription>Total companies</CardDescription>
<CardDescription>{t('admin.companies.stats.total')}</CardDescription>
<CardTitle className="text-3xl">{totalCompanies}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Active companies</CardDescription>
<CardDescription>{t('admin.companies.stats.active')}</CardDescription>
<CardTitle className="text-3xl">{companies.filter((c) => c.active).length}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Verified</CardDescription>
<CardDescription>{t('admin.companies.stats.verified')}</CardDescription>
<CardTitle className="text-3xl">{companies.filter((c) => c.verified).length}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Pending</CardDescription>
<CardDescription>{t('admin.companies.stats.pending')}</CardDescription>
<CardTitle className="text-3xl">{companies.filter((c) => !c.verified).length}</CardTitle>
</CardHeader>
</Card>
@ -343,7 +496,7 @@ export default function AdminCompaniesPage() {
<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="Search companies by name or email..."
placeholder={t('admin.companies.searchPlaceholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
@ -364,19 +517,19 @@ export default function AdminCompaniesPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>Company</TableHead>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
<TableHead>Verified</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead>{t('admin.companies.table.company')}</TableHead>
<TableHead>{t('admin.companies.table.email')}</TableHead>
<TableHead>{t('admin.companies.table.status')}</TableHead>
<TableHead>{t('admin.companies.table.verified')}</TableHead>
<TableHead>{t('admin.companies.table.created')}</TableHead>
<TableHead className="text-right">{t('admin.companies.table.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCompanies.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
No companies found
{t('admin.companies.table.empty')}
</TableCell>
</TableRow>
) : (
@ -395,7 +548,7 @@ export default function AdminCompaniesPage() {
className="cursor-pointer hover:opacity-80"
onClick={() => toggleStatus(company, 'active')}
>
{company.active ? "Active" : "Inactive"}
{company.active ? t('admin.companies.fields.active') : t('admin.companies.fields.inactive')}
</Badge>
</TableCell>
<TableCell>
@ -414,9 +567,17 @@ export default function AdminCompaniesPage() {
{company.createdAt ? companyDateFormatter.format(new Date(company.createdAt)) : "-"}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" onClick={() => handleView(company)}>
<Eye className="h-4 w-4" />
</Button>
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="icon" onClick={() => handleView(company)}>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleEditClick(company)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="text-destructive hover:text-destructive" onClick={() => handleDelete(company)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
@ -428,8 +589,12 @@ export default function AdminCompaniesPage() {
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground mt-4">
<span>
{totalCompanies === 0
? "No companies to display"
: `Showing ${(page - 1) * limit + 1}-${Math.min(page * limit, totalCompanies)} of ${totalCompanies}`}
? t('admin.companies.table.empty')
: t('admin.companies.table.showing', {
from: (page - 1) * limit + 1,
to: Math.min(page * limit, totalCompanies),
total: totalCompanies
})}
</span>
<div className="flex items-center gap-2">
<Button
@ -464,17 +629,17 @@ export default function AdminCompaniesPage() {
<Building2 className="h-5 w-5" />
{selectedCompany?.name}
</DialogTitle>
<DialogDescription>Company details and information</DialogDescription>
<DialogDescription>{t('admin.companies.details.subtitle')}</DialogDescription>
</DialogHeader>
{selectedCompany && (
<div className="space-y-6 py-4">
{/* Status Badges */}
<div className="flex gap-2">
<Badge variant={selectedCompany.active ? "default" : "secondary"}>
{selectedCompany.active ? "Active" : "Inactive"}
{selectedCompany.active ? t('admin.companies.fields.active') : t('admin.companies.fields.inactive')}
</Badge>
<Badge variant={selectedCompany.verified ? "default" : "outline"}>
{selectedCompany.verified ? "Verified" : "Not Verified"}
{selectedCompany.verified ? t('admin.companies.stats.verified') : "Not Verified"}
</Badge>
{selectedCompany.type && (
<Badge variant="outline">{selectedCompany.type}</Badge>
@ -484,19 +649,19 @@ export default function AdminCompaniesPage() {
{/* Basic Info */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-muted-foreground text-xs">Slug</Label>
<Label className="text-muted-foreground text-xs">{t('admin.companies.create.slug')}</Label>
<p className="font-mono text-sm">{selectedCompany.slug}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">Email</Label>
<Label className="text-muted-foreground text-xs">{t('admin.companies.fields.email')}</Label>
<p className="text-sm">{selectedCompany.email || "-"}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">Phone</Label>
<Label className="text-muted-foreground text-xs">{t('admin.companies.fields.phone')}</Label>
<p className="text-sm">{selectedCompany.phone || "-"}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">Website</Label>
<Label className="text-muted-foreground text-xs">{t('admin.companies.fields.website')}</Label>
<p className="text-sm">
{selectedCompany.website ? (
<a
@ -513,11 +678,11 @@ export default function AdminCompaniesPage() {
</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">Document (CNPJ)</Label>
<Label className="text-muted-foreground text-xs">{t('admin.companies.fields.document')}</Label>
<p className="text-sm font-mono">{selectedCompany.document || "-"}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">Address</Label>
<Label className="text-muted-foreground text-xs">{t('admin.companies.fields.address')}</Label>
<p className="text-sm">{selectedCompany.address || "-"}</p>
</div>
</div>
@ -525,7 +690,7 @@ export default function AdminCompaniesPage() {
{/* Description */}
{selectedCompany.description && (
<div>
<Label className="text-muted-foreground text-xs">Description</Label>
<Label className="text-muted-foreground text-xs">{t('admin.companies.fields.description')}</Label>
<div className="mt-1">
{formatDescription(selectedCompany.description)}
</div>
@ -535,7 +700,7 @@ export default function AdminCompaniesPage() {
{/* Timestamps */}
<div className="grid grid-cols-2 gap-4 pt-4 border-t">
<div>
<Label className="text-muted-foreground text-xs">Created At</Label>
<Label className="text-muted-foreground text-xs">{t('admin.companies.fields.createdAt')}</Label>
<p className="text-sm">
{selectedCompany.createdAt
? companyDateFormatter.format(new Date(selectedCompany.createdAt))
@ -543,7 +708,7 @@ export default function AdminCompaniesPage() {
</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">Updated At</Label>
<Label className="text-muted-foreground text-xs">{t('admin.companies.fields.updatedAt')}</Label>
<p className="text-sm">
{selectedCompany.updatedAt
? companyDateFormatter.format(new Date(selectedCompany.updatedAt))
@ -560,17 +725,17 @@ export default function AdminCompaniesPage() {
onClick={() => handleDelete(selectedCompany)}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
{t('admin.companies.details.delete')}
</Button>
)}
<div className="flex gap-2">
<Button variant="outline" onClick={() => setIsViewDialogOpen(false)}>
Close
{t('admin.companies.details.close')}
</Button>
{selectedCompany && (
<Button onClick={() => handleEditClick(selectedCompany)}>
<Pencil className="h-4 w-4 mr-2" />
Edit
{t('admin.companies.details.edit')}
</Button>
)}
</div>
@ -581,8 +746,8 @@ export default function AdminCompaniesPage() {
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit company</DialogTitle>
<DialogDescription>Update company information</DialogDescription>
<DialogTitle>{t('admin.companies.edit.title')}</DialogTitle>
<DialogDescription>{t('admin.companies.edit.subtitle')}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="flex items-center gap-4 border p-4 rounded-md">
@ -592,7 +757,7 @@ export default function AdminCompaniesPage() {
onCheckedChange={(checked) => setEditFormData({ ...editFormData, active: checked })}
id="edit-active"
/>
<Label htmlFor="edit-active">Active</Label>
<Label htmlFor="edit-active">{t('admin.companies.fields.active')}</Label>
</div>
<div className="flex items-center gap-2">
<Switch
@ -600,11 +765,11 @@ export default function AdminCompaniesPage() {
onCheckedChange={(checked) => setEditFormData({ ...editFormData, verified: checked })}
id="edit-verified"
/>
<Label htmlFor="edit-verified">Verified</Label>
<Label htmlFor="edit-verified">{t('admin.companies.stats.verified')}</Label>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-name">Company name</Label>
<Label htmlFor="edit-name">{t('admin.companies.create.name')}</Label>
<Input
id="edit-name"
value={editFormData.name}
@ -612,7 +777,7 @@ export default function AdminCompaniesPage() {
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-slug">Slug</Label>
<Label htmlFor="edit-slug">{t('admin.companies.create.slug')}</Label>
<Input
id="edit-slug"
value={editFormData.slug}
@ -620,7 +785,7 @@ export default function AdminCompaniesPage() {
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-email">Email</Label>
<Label htmlFor="edit-email">{t('admin.companies.create.email')}</Label>
<Input
id="edit-email"
value={editFormData.email}
@ -628,7 +793,7 @@ export default function AdminCompaniesPage() {
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-phone">Phone</Label>
<Label htmlFor="edit-phone">{t('admin.companies.fields.phone')}</Label>
<Input
id="edit-phone"
value={editFormData.phone}
@ -636,7 +801,7 @@ export default function AdminCompaniesPage() {
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-website">Website</Label>
<Label htmlFor="edit-website">{t('admin.companies.fields.website')}</Label>
<Input
id="edit-website"
value={editFormData.website}
@ -644,7 +809,7 @@ export default function AdminCompaniesPage() {
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-document">Document</Label>
<Label htmlFor="edit-document">{t('admin.companies.fields.document')}</Label>
<Input
id="edit-document"
value={editFormData.document}
@ -652,7 +817,7 @@ export default function AdminCompaniesPage() {
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-address">Address</Label>
<Label htmlFor="edit-address">{t('admin.companies.fields.address')}</Label>
<Input
id="edit-address"
value={editFormData.address}
@ -660,7 +825,7 @@ export default function AdminCompaniesPage() {
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description">Description</Label>
<Label htmlFor="edit-description">{t('admin.companies.fields.description')}</Label>
<Input
id="edit-description"
value={editFormData.description}
@ -669,10 +834,10 @@ export default function AdminCompaniesPage() {
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>Cancel</Button>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>{t('admin.companies.create.cancel')}</Button>
<Button onClick={handleUpdate} disabled={updating}>
{updating && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save changes
{t('admin.companies.edit.save')}
</Button>
</DialogFooter>
</DialogContent>

View file

@ -11,10 +11,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Separator } from "@/components/ui/separator"
import { ArrowLeft, Loader2, Building2, DollarSign, FileText, Briefcase, MapPin, Clock } from "lucide-react"
import { jobsApi, adminCompaniesApi, type CreateJobPayload, type AdminCompany } from "@/lib/api"
import { useTranslation } from "@/lib/i18n"
import { toast } from "sonner"
export default function NewJobPage() {
const router = useRouter()
const { t } = useTranslation()
const [isSubmitting, setIsSubmitting] = useState(false)
const [companies, setCompanies] = useState<AdminCompany[]>([])
const [loadingCompanies, setLoadingCompanies] = useState(true)
@ -104,8 +106,8 @@ export default function NewJobPage() {
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-3xl font-bold">Post a job</h1>
<p className="text-muted-foreground">Fill in the details below to create your job listing</p>
<h1 className="text-3xl font-bold">{t('admin.jobs.newJob')}</h1>
<p className="text-muted-foreground">{t('admin.jobs.edit.subtitle')}</p>
</div>
</div>
@ -114,13 +116,13 @@ export default function NewJobPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Job Details
{t('admin.jobs.details.title')}
</CardTitle>
<CardDescription>Basic information about this position</CardDescription>
<CardDescription>{t('admin.jobs.details.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Job Title *</Label>
<Label htmlFor="title">{t('admin.jobs.edit.jobTitle')} *</Label>
<Input
id="title"
placeholder="e.g. Senior Software Engineer"
@ -132,7 +134,7 @@ export default function NewJobPage() {
)}
</div>
<div className="space-y-2">
<Label htmlFor="description">Job Description *</Label>
<Label htmlFor="description">{t('admin.jobs.details.description')} *</Label>
<Textarea
id="description"
placeholder="Describe the role, responsibilities, and requirements..."
@ -147,7 +149,7 @@ export default function NewJobPage() {
<div className="space-y-2">
<Label htmlFor="location" className="flex items-center gap-1">
<MapPin className="h-4 w-4" />
Location
{t('admin.candidates_page.table.location')}
</Label>
<Input
id="location"
@ -257,7 +259,7 @@ export default function NewJobPage() {
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label>Company *</Label>
<Label>{t('admin.jobs.table.company')} *</Label>
{loadingCompanies ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
@ -288,7 +290,7 @@ export default function NewJobPage() {
{/* Action Buttons */}
<div className="flex justify-between items-center">
<Button variant="outline" onClick={() => router.back()}>
Cancel
{t('admin.jobs.edit.cancel')}
</Button>
<div className="flex gap-3">
<Button
@ -317,7 +319,7 @@ export default function NewJobPage() {
) : (
<>
<Briefcase className="h-4 w-4 mr-2" />
Publish Job
{t('admin.jobs.edit.save')}
</>
)}
</Button>

View file

@ -14,13 +14,11 @@ import {
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, ChevronLeft, ChevronRight } from "lucide-react"
import { adminJobsApi, adminCompaniesApi, jobsApi, type AdminJob, type AdminCompany } from "@/lib/api"
import { useTranslation } from "@/lib/i18n"
type AdminJobRow = {
id: string
@ -38,16 +36,15 @@ type AdminJobRow = {
}
export default function AdminJobsPage() {
const { t } = useTranslation()
const [searchTerm, setSearchTerm] = useState("")
const [jobs, setJobs] = useState<AdminJob[]>([])
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [selectedJob, setSelectedJob] = useState<AdminJobRow | null>(null)
const [editForm, setEditForm] = useState<{ title?: string }>({})
const [isLoading, setIsLoading] = useState(true)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [companies, setCompanies] = useState<AdminCompany[]>([])
// Pagination State
const [page, setPage] = useState(1)
@ -55,16 +52,6 @@ export default function AdminJobsPage() {
const [totalPages, setTotalPages] = useState(1)
const [totalJobs, setTotalJobs] = useState(0)
const [createForm, setCreateForm] = useState({
title: "",
company: "",
location: "",
type: "",
level: "",
salary: "",
description: "",
})
useEffect(() => {
const loadJobs = async () => {
try {
@ -73,13 +60,6 @@ export default function AdminJobsPage() {
// Fetch with pagination
const jobsData = await adminJobsApi.list({ limit, page })
setJobs(jobsData.data ?? [])
// Assuming metadata contains total/page info, or fallback if not available
// Need to check API response structure broadly, but assuming standard "meta" or similar
// For now, if no explicit meta, we rely on checking array length vs limit as a heuristic?
// Wait, adminJobsApi.list returns Promise<{ data: AdminJob[], meta?: ... }> ?
// Let's assume standard response for now. If API doesn't return total, we might need a separate count call or API update.
// Checking `adminJobsApi.list` later if issues arise. Assuming it returns `total` somewhere if needed.
// For now preventing errors:
if (jobsData.pagination) {
setTotalPages(Math.ceil((jobsData.pagination.total || 0) / limit))
setTotalJobs(jobsData.pagination.total || 0)
@ -90,7 +70,7 @@ export default function AdminJobsPage() {
} catch (error) {
console.error("Failed to load jobs:", error)
setErrorMessage("Unable to load jobs right now.")
setErrorMessage(t('admin.jobs.table.error'))
setJobs([])
} finally {
setIsLoading(false)
@ -98,18 +78,7 @@ export default function AdminJobsPage() {
}
loadJobs()
// Load companies (keep this as looks like independent lookup)
const loadCompanies = async () => {
try {
const companiesData = await adminCompaniesApi.list(undefined, 1, 100)
setCompanies(companiesData.data ?? [])
} catch (error) {
console.error("[DEBUG] Failed to load companies:", error)
}
}
loadCompanies()
}, [page, limit]) // Reload when page changes
}, [page, limit, t])
const jobRows = useMemo<AdminJobRow[]>(
() =>
@ -175,16 +144,14 @@ export default function AdminJobsPage() {
const handleDeleteJob = async (id: string) => {
console.log("[JOBS_PAGE] handleDeleteJob called with id:", id)
if (!confirm("Are you sure you want to delete this job?")) return
if (!confirm(t('admin.jobs.deleteConfirm'))) return
try {
console.log("[JOBS_PAGE] Calling jobsApi.delete...")
await jobsApi.delete(id)
console.log("[JOBS_PAGE] Job deleted successfully, updating local state")
setJobs((prevJobs) => prevJobs.filter((job) => job.id !== id))
} catch (error) {
console.error("[JOBS_PAGE] Failed to delete job:", error)
alert("Failed to delete job")
alert(t('admin.jobs.deleteError'))
}
}
@ -192,21 +159,16 @@ export default function AdminJobsPage() {
const handleSaveEdit = async () => {
if (!selectedJob) return
console.log("[JOBS_PAGE] handleSaveEdit called for job:", selectedJob.id)
console.log("[JOBS_PAGE] Edit form data:", editForm)
try {
setIsLoading(true)
console.log("[JOBS_PAGE] Calling jobsApi.update...")
const updated = await jobsApi.update(selectedJob.id, editForm)
console.log("[JOBS_PAGE] Job updated successfully:", updated)
await jobsApi.update(selectedJob.id, editForm)
// Reload jobs to get fresh data
const jobsData = await adminJobsApi.list({ limit: 10, page: 1 })
setJobs(jobsData.data ?? [])
setIsEditDialogOpen(false)
} catch (error) {
console.error("[JOBS_PAGE] Failed to update job:", error)
alert("Failed to update job")
alert(t('admin.jobs.updateError'))
} finally {
setIsLoading(false)
}
@ -218,7 +180,6 @@ export default function AdminJobsPage() {
}
const handleNextPage = () => {
// If we rely on generic "if array < limit then end" logic or strict meta total pages
if (page < totalPages) setPage(page + 1)
}
@ -227,13 +188,13 @@ export default function AdminJobsPage() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Job management</h1>
<p className="text-muted-foreground mt-1">Manage all jobs posted on the platform</p>
<h1 className="text-3xl font-bold text-foreground">{t('admin.jobs.title')}</h1>
<p className="text-muted-foreground mt-1">{t('admin.jobs.subtitle')}</p>
</div>
<Link href="/dashboard/jobs/new">
<Button className="gap-2">
<Plus className="h-4 w-4" />
New job
{t('admin.jobs.newJob')}
</Button>
</Link>
</div>
@ -242,33 +203,30 @@ export default function AdminJobsPage() {
<Dialog open={isViewDialogOpen} onOpenChange={setIsViewDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Job Details</DialogTitle>
<DialogTitle>{t('admin.jobs.details.title')}</DialogTitle>
</DialogHeader>
{selectedJob && (
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-muted-foreground">Title</Label>
<Label className="text-muted-foreground">{t('admin.jobs.edit.jobTitle')}</Label>
<p className="font-medium">{selectedJob.title}</p>
</div>
<div>
<Label className="text-muted-foreground">Company</Label>
<Label className="text-muted-foreground">{t('admin.jobs.table.company')}</Label>
<p className="font-medium">{selectedJob.company}</p>
</div>
{/* Location and Type removed from table but kept in dialog if data exists, valid?
User asked to remove from "table" mainly. Keeping detail view intact is safer.
But wait, selectedJob still has them (empty strings). */}
<div>
<Label className="text-muted-foreground">Status</Label>
<Label className="text-muted-foreground">{t('admin.jobs.table.status')}</Label>
<p><Badge>{selectedJob.status}</Badge></p>
</div>
<div>
<Label className="text-muted-foreground">Applications</Label>
<Label className="text-muted-foreground">{t('admin.jobs.table.applications')}</Label>
<p className="font-medium">{selectedJob.applicationsCount}</p>
</div>
</div>
<div>
<Label className="text-muted-foreground">Description</Label>
<Label className="text-muted-foreground">{t('admin.jobs.details.description')}</Label>
<div className="mt-1 p-3 bg-muted rounded-md text-sm whitespace-pre-wrap max-h-60 overflow-y-auto">
{selectedJob.description}
</div>
@ -276,7 +234,7 @@ export default function AdminJobsPage() {
</div>
)}
<DialogFooter>
<Button onClick={() => setIsViewDialogOpen(false)}>Close</Button>
<Button onClick={() => setIsViewDialogOpen(false)}>{t('admin.jobs.details.close')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@ -285,12 +243,12 @@ export default function AdminJobsPage() {
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit Job</DialogTitle>
<DialogDescription>Update job details</DialogDescription>
<DialogTitle>{t('admin.jobs.edit.title')}</DialogTitle>
<DialogDescription>{t('admin.jobs.edit.subtitle')}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-title">Job title</Label>
<Label htmlFor="edit-title">{t('admin.jobs.edit.jobTitle')}</Label>
<Input
id="edit-title"
value={editForm.title || ""}
@ -299,8 +257,8 @@ export default function AdminJobsPage() {
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSaveEdit}>Save Changes</Button>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>{t('admin.jobs.edit.cancel')}</Button>
<Button onClick={handleSaveEdit}>{t('admin.jobs.edit.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@ -309,25 +267,25 @@ export default function AdminJobsPage() {
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-3">
<CardDescription>Total jobs</CardDescription>
<CardDescription>{t('admin.jobs.stats.total')}</CardDescription>
<CardTitle className="text-3xl">{jobs.length}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Active jobs</CardDescription>
<CardDescription>{t('admin.jobs.stats.active')}</CardDescription>
<CardTitle className="text-3xl">{activeJobs}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Applications</CardDescription>
<CardDescription>{t('admin.jobs.stats.applications')}</CardDescription>
<CardTitle className="text-3xl">{totalApplications}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Conversion rate</CardDescription>
<CardDescription>{t('admin.jobs.stats.conversion')}</CardDescription>
<CardTitle className="text-3xl">
{jobs.length > 0 ? Math.round((activeJobs / jobs.length) * 100) : 0}%
</CardTitle>
@ -342,7 +300,7 @@ export default function AdminJobsPage() {
<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="Search jobs by title or company..."
placeholder={t('admin.jobs.searchPlaceholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
@ -354,19 +312,18 @@ export default function AdminJobsPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>Role</TableHead>
<TableHead>Company</TableHead>
{/* Removed Location and Type Headers */}
<TableHead>Applications</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead>{t('admin.jobs.table.role')}</TableHead>
<TableHead>{t('admin.jobs.table.company')}</TableHead>
<TableHead>{t('admin.jobs.table.applications')}</TableHead>
<TableHead>{t('admin.jobs.table.status')}</TableHead>
<TableHead className="text-right">{t('admin.jobs.table.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
Loading jobs...
{t('admin.jobs.table.loading')}
</TableCell>
</TableRow>
) : errorMessage ? (
@ -378,7 +335,7 @@ export default function AdminJobsPage() {
) : filteredJobs.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No jobs found.
{t('admin.jobs.table.empty')}
</TableCell>
</TableRow>
) : (
@ -386,7 +343,6 @@ export default function AdminJobsPage() {
<TableRow key={job.id}>
<TableCell className="font-medium">{job.title}</TableCell>
<TableCell>{job.company}</TableCell>
{/* Removed Location and Type Cells */}
<TableCell>{job.applicationsCount ?? 0}</TableCell>
<TableCell>
<Badge variant="default">{job.status ?? "Active"}</Badge>

View file

@ -227,11 +227,11 @@ export default function AdminUsersPage() {
const getRoleBadge = (role: string) => {
const labels: Record<string, string> = {
superadmin: "Super Admin",
admin: "Company Admin",
recruiter: "Recruiter",
candidate: "Candidate",
company: "Company"
superadmin: t('admin.users.roles.superadmin'),
admin: t('admin.users.roles.admin'),
recruiter: t('admin.users.roles.recruiter'),
candidate: t('admin.users.roles.candidate'),
company: t('admin.users.roles.admin') // Fallback for 'company' role legacy
}
const colors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
superadmin: "destructive",
@ -295,15 +295,15 @@ export default function AdminUsersPage() {
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="Secure password"
placeholder={t('admin.users.form.password')}
/>
</div>
{currentUser?.role === 'superadmin' && (
<div className="grid gap-2">
<Label htmlFor="company">Company</Label>
<Label htmlFor="company">{t('admin.users.form.company')}</Label>
<Select value={formData.companyId} onValueChange={(v) => setFormData({ ...formData, companyId: v })}>
<SelectTrigger>
<SelectValue placeholder="Select a company" />
<SelectValue placeholder={t('admin.users.form.select_company')} />
</SelectTrigger>
<SelectContent>
{companies.map((company) => (
@ -319,11 +319,11 @@ export default function AdminUsersPage() {
<Label htmlFor="status">{t('admin.users.table.status')}</Label>
<Select value={formData.status} onValueChange={(v) => setFormData({ ...formData, status: v })}>
<SelectTrigger>
<SelectValue />
<SelectValue placeholder={t('admin.users.form.status_placeholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="active">{t('admin.users.statuses.active')}</SelectItem>
<SelectItem value="inactive">{t('admin.users.statuses.inactive')}</SelectItem>
</SelectContent>
</Select>
</div>
@ -334,10 +334,10 @@ export default function AdminUsersPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="superadmin">Super Admin</SelectItem>
<SelectItem value="admin">Company admin</SelectItem>
<SelectItem value="recruiter">Recruiter</SelectItem>
<SelectItem value="candidate">Candidate</SelectItem>
<SelectItem value="superadmin">{t('admin.users.roles.superadmin')}</SelectItem>
<SelectItem value="admin">{t('admin.users.roles.admin')}</SelectItem>
<SelectItem value="recruiter">{t('admin.users.roles.recruiter')}</SelectItem>
<SelectItem value="candidate">{t('admin.users.roles.candidate')}</SelectItem>
</SelectContent>
</Select>
</div>
@ -385,7 +385,7 @@ export default function AdminUsersPage() {
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-role">Role</Label>
<Label htmlFor="edit-role">{t('admin.users.table.role')}</Label>
<Select
value={editFormData.role}
onValueChange={(v) => setEditFormData({ ...editFormData, role: v })}
@ -395,15 +395,15 @@ export default function AdminUsersPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="superadmin">Super Admin</SelectItem>
<SelectItem value="admin">Company admin</SelectItem>
<SelectItem value="recruiter">Recruiter</SelectItem>
<SelectItem value="candidate">Candidate</SelectItem>
<SelectItem value="superadmin">{t('admin.users.roles.superadmin')}</SelectItem>
<SelectItem value="admin">{t('admin.users.roles.admin')}</SelectItem>
<SelectItem value="recruiter">{t('admin.users.roles.recruiter')}</SelectItem>
<SelectItem value="candidate">{t('admin.users.roles.candidate')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-status">Status</Label>
<Label htmlFor="edit-status">{t('admin.users.table.status')}</Label>
<Select
value={editFormData.status}
onValueChange={(v) => setEditFormData({ ...editFormData, status: v })}
@ -413,8 +413,8 @@ export default function AdminUsersPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="active">{t('admin.users.statuses.active')}</SelectItem>
<SelectItem value="inactive">{t('admin.users.statuses.inactive')}</SelectItem>
</SelectContent>
</Select>
</div>

View file

@ -907,6 +907,23 @@
"delete_success": "User deleted!",
"delete_error": "Failed to delete user",
"load_error": "Failed to load users"
},
"form": {
"password": "Secure password",
"company": "Company",
"select_company": "Select a company",
"role_placeholder": "Select a role",
"status_placeholder": "Select status"
},
"roles": {
"superadmin": "Super Admin",
"admin": "Company Admin",
"recruiter": "Recruiter",
"candidate": "Candidate"
},
"statuses": {
"active": "Active",
"inactive": "Inactive"
}
},
"candidates_page": {
@ -949,6 +966,114 @@
"hired": "Hired",
"rejected": "Rejected"
}
},
"jobs": {
"title": "Job management",
"subtitle": "Manage all jobs posted on the platform",
"newJob": "New job",
"stats": {
"total": "Total jobs",
"active": "Active jobs",
"applications": "Applications",
"conversion": "Conversion rate"
},
"searchPlaceholder": "Search jobs by title or company...",
"table": {
"role": "Role",
"company": "Company",
"applications": "Applications",
"status": "Status",
"actions": "Actions",
"empty": "No jobs found.",
"loading": "Loading jobs...",
"error": "Unable to load jobs right now."
},
"details": {
"title": "Job Details",
"description": "Job description",
"close": "Close"
},
"edit": {
"title": "Edit Job",
"subtitle": "Update job details",
"jobTitle": "Job title",
"cancel": "Cancel",
"save": "Save Changes"
},
"deleteConfirm": "Are you sure you want to delete this job?",
"deleteError": "Failed to delete job",
"updateError": "Failed to update job"
},
"companies": {
"title": "Company management",
"subtitle": "Manage all registered companies",
"newCompany": "New company",
"refresh": "Refresh",
"stats": {
"total": "Total companies",
"active": "Active companies",
"verified": "Verified",
"pending": "Pending"
},
"create": {
"title": "Create new company",
"subtitle": "Fill in the company details",
"name": "Company name",
"namePlaceholder": "Company XYZ",
"slug": "Slug (URL)",
"slugPlaceholder": "company-xyz",
"email": "Email",
"emailPlaceholder": "hello@company.com",
"cancel": "Cancel",
"submit": "Create company"
},
"edit": {
"title": "Edit company",
"subtitle": "Update company information",
"save": "Save changes"
},
"details": {
"title": "Company details",
"subtitle": "Company details and information",
"close": "Close",
"delete": "Delete",
"edit": "Edit"
},
"table": {
"company": "Company",
"email": "Email",
"status": "Status",
"verified": "Verified",
"created": "Created",
"actions": "Actions",
"empty": "No companies found",
"showing": "Showing {{from}}-{{to}} of {{total}}"
},
"searchPlaceholder": "Search companies by name or email...",
"deleteConfirm": "Are you sure you want to delete {{name}}? This action cannot be undone.",
"success": {
"created": "Company created successfully!",
"deleted": "Company deleted successfully",
"updated": "Company updated successfully",
"statusUpdated": "Company {{field}} updated"
},
"fields": {
"active": "Active",
"inactive": "Inactive",
"address": "Address",
"phone": "Phone",
"email": "Email",
"website": "Website",
"document": "Document (CNPJ)",
"description": "Description",
"password": "Password",
"confirmPassword": "Confirm Password",
"showPassword": "Show password",
"hidePassword": "Hide password",
"passwordsDoNotMatch": "Passwords do not match",
"createdAt": "Created At",
"updatedAt": "Updated At"
}
}
},
"company": {

View file

@ -967,6 +967,23 @@
"delete_success": "Usuário excluído!",
"delete_error": "Falha ao excluir usuário",
"load_error": "Falha ao carregar usuários"
},
"form": {
"password": "Senha segura",
"company": "Empresa",
"select_company": "Selecione uma empresa",
"role_placeholder": "Selecione uma função",
"status_placeholder": "Selecione o status"
},
"roles": {
"superadmin": "Super Admin",
"admin": "Admin da Empresa",
"recruiter": "Recrutador",
"candidate": "Candidato"
},
"statuses": {
"active": "Ativo",
"inactive": "Inativo"
}
},
"candidates_page": {
@ -1009,6 +1026,114 @@
"hired": "Contratado",
"rejected": "Rejeitado"
}
},
"jobs": {
"title": "Gerenciamento de Vagas",
"subtitle": "Gerencie todas as vagas publicadas na plataforma",
"newJob": "Nova vaga",
"stats": {
"total": "Total de vagas",
"active": "Vagas ativas",
"applications": "Candidaturas",
"conversion": "Taxa de conversão"
},
"searchPlaceholder": "Buscar vagas por título ou empresa...",
"table": {
"role": "Cargo",
"company": "Empresa",
"applications": "Candidaturas",
"status": "Status",
"actions": "Ações",
"empty": "Nenhuma vaga encontrada.",
"loading": "Carregando vagas...",
"error": "Não foi possível carregar as vagas."
},
"details": {
"title": "Detalhes da Vaga",
"description": "Descrição da vaga",
"close": "Fechar"
},
"edit": {
"title": "Editar Vaga",
"subtitle": "Atualizar detalhes da vaga",
"jobTitle": "Título da vaga",
"cancel": "Cancelar",
"save": "Salvar Alterações"
},
"deleteConfirm": "Tem certeza que deseja excluir esta vaga?",
"deleteError": "Falha ao excluir vaga",
"updateError": "Falha ao atualizar vaga"
},
"companies": {
"title": "Gerenciamento de Empresas",
"subtitle": "Gerencie todas as empresas registradas",
"newCompany": "Nova empresa",
"refresh": "Atualizar",
"stats": {
"total": "Total de empresas",
"active": "Empresas ativas",
"verified": "Verificadas",
"pending": "Pendentes"
},
"create": {
"title": "Criar nova empresa",
"subtitle": "Preencha os dados da empresa",
"name": "Nome da empresa",
"namePlaceholder": "Empresa XYZ",
"slug": "Slug (URL)",
"slugPlaceholder": "empresa-xyz",
"email": "Email",
"emailPlaceholder": "ola@empresa.com",
"cancel": "Cancelar",
"submit": "Criar empresa"
},
"edit": {
"title": "Editar empresa",
"subtitle": "Atualizar informações da empresa",
"save": "Salvar alterações"
},
"details": {
"title": "Detalhes da empresa",
"subtitle": "Informações e detalhes da empresa",
"close": "Fechar",
"delete": "Excluir",
"edit": "Editar"
},
"table": {
"company": "Empresa",
"email": "Email",
"status": "Status",
"verified": "Verificado",
"created": "Criado em",
"actions": "Ações",
"empty": "Nenhuma empresa encontrada",
"showing": "Exibindo {{from}}-{{to}} de {{total}}"
},
"searchPlaceholder": "Buscar empresas por nome ou email...",
"deleteConfirm": "Tem certeza que deseja excluir {{name}}? Esta ação não pode ser desfeita.",
"success": {
"created": "Empresa criada com sucesso!",
"deleted": "Empresa excluída com sucesso",
"updated": "Empresa atualizada com sucesso",
"statusUpdated": "Empresa {{field}} atualizada"
},
"fields": {
"active": "Ativo",
"inactive": "Inativo",
"address": "Endereço",
"phone": "Telefone",
"email": "Email",
"website": "Site",
"document": "Documento (CNPJ)",
"description": "Descrição",
"password": "Senha",
"confirmPassword": "Confirmar Senha",
"showPassword": "Mostrar senha",
"hidePassword": "Ocultar senha",
"passwordsDoNotMatch": "Senhas não conferem",
"createdAt": "Criado em",
"updatedAt": "Atualizado em"
}
}
},
"company": {