gohorsejobs/frontend/src/app/dashboard/jobs/page.tsx
2026-03-07 11:06:47 -03:00

621 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
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"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Plus, Search, Edit, Trash2, Eye, ChevronLeft, ChevronRight, Loader2 } from "lucide-react"
import { adminJobsApi, jobsApi, type AdminJob } from "@/lib/api"
import { useTranslation } from "@/lib/i18n"
import { ConfirmModal } from "@/components/confirm-modal"
type EditForm = {
title: string
description: string
location: string
employmentType: string
workMode: string
workingHours: string
salaryMin: string
salaryMax: string
salaryType: string
currency: string
salaryNegotiable: boolean
languageLevel: string
visaSupport: boolean
status: string
isFeatured: boolean
}
const defaultEditForm: EditForm = {
title: "",
description: "",
location: "",
employmentType: "",
workMode: "",
workingHours: "",
salaryMin: "",
salaryMax: "",
salaryType: "monthly",
currency: "BRL",
salaryNegotiable: false,
languageLevel: "",
visaSupport: false,
status: "open",
isFeatured: false,
}
export default function AdminJobsPage() {
const { t } = useTranslation()
const [searchTerm, setSearchTerm] = useState("")
const [jobs, setJobs] = useState<AdminJob[]>([])
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [selectedJob, setSelectedJob] = useState<AdminJob | null>(null)
const [editForm, setEditForm] = useState<EditForm>(defaultEditForm)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [deleteConfirmDialog, setDeleteConfirmDialog] = useState<{ isOpen: boolean, jobId: string | null }>({ isOpen: false, jobId: null })
const [page, setPage] = useState(1)
const [limit] = useState(10)
const [totalPages, setTotalPages] = useState(1)
const [totalJobs, setTotalJobs] = useState(0)
const loadJobs = async (targetPage = page) => {
try {
setIsLoading(true)
setErrorMessage(null)
const jobsData = await adminJobsApi.list({ limit, page: targetPage })
setJobs(jobsData.data ?? [])
if (jobsData.pagination) {
setTotalPages(Math.ceil((jobsData.pagination.total || 0) / limit))
setTotalJobs(jobsData.pagination.total || 0)
}
} catch (error) {
console.error("Failed to load jobs:", error)
setErrorMessage(t('admin.jobs.table.error'))
setJobs([])
} finally {
setIsLoading(false)
}
}
useEffect(() => { loadJobs() }, [page, limit])
const filteredJobs = useMemo(
() => jobs.filter(
(job) =>
job.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
job.companyName.toLowerCase().includes(searchTerm.toLowerCase()),
),
[jobs, searchTerm],
)
const activeJobs = useMemo(
() => jobs.filter((job) => ["published", "open", "active"].includes(job.status?.toLowerCase() ?? "")).length,
[jobs],
)
const totalApplications = useMemo(
() => jobs.reduce((sum, job) => sum + (job.applicationsCount ?? 0), 0),
[jobs],
)
const handleViewJob = (job: AdminJob) => {
setSelectedJob(job)
setIsViewDialogOpen(true)
}
const handleEditJob = (job: AdminJob) => {
setSelectedJob(job)
setEditForm({
title: job.title ?? "",
description: job.description ?? "",
location: job.location ?? "",
employmentType: job.employmentType ?? "",
workMode: job.workMode ?? "",
workingHours: job.workingHours ?? "",
salaryMin: job.salaryMin != null ? String(job.salaryMin) : "",
salaryMax: job.salaryMax != null ? String(job.salaryMax) : "",
salaryType: job.salaryType ?? "monthly",
currency: job.currency ?? "BRL",
salaryNegotiable: job.salaryNegotiable ?? false,
languageLevel: job.languageLevel ?? "",
visaSupport: job.visaSupport ?? false,
status: job.status ?? "open",
isFeatured: job.isFeatured ?? false,
})
setIsEditDialogOpen(true)
}
const handleDeleteJob = async (id: string) => {
setDeleteConfirmDialog({ isOpen: true, jobId: id })
}
const confirmDeleteJob = async () => {
if (!deleteConfirmDialog.jobId) return
try {
await jobsApi.delete(deleteConfirmDialog.jobId)
setJobs((prev) => prev.filter((job) => job.id !== deleteConfirmDialog.jobId))
} catch {
alert(t('admin.jobs.deleteError'))
} finally {
setDeleteConfirmDialog({ isOpen: false, jobId: null })
}
}
const handleSaveEdit = async () => {
if (!selectedJob) return
setIsSaving(true)
try {
await jobsApi.update(selectedJob.id, {
title: editForm.title || undefined,
description: editForm.description || undefined,
location: editForm.location || undefined,
employmentType: (editForm.employmentType as any) || undefined,
workMode: (editForm.workMode as any) || undefined,
workingHours: editForm.workingHours || undefined,
salaryMin: editForm.salaryMin ? parseFloat(editForm.salaryMin) : undefined,
salaryMax: editForm.salaryMax ? parseFloat(editForm.salaryMax) : undefined,
salaryType: (editForm.salaryType as any) || undefined,
currency: (editForm.currency as any) || undefined,
salaryNegotiable: editForm.salaryNegotiable,
languageLevel: editForm.languageLevel || undefined,
visaSupport: editForm.visaSupport,
status: (editForm.status as any) || undefined,
isFeatured: editForm.isFeatured,
})
await loadJobs(page)
setIsEditDialogOpen(false)
} catch {
alert(t('admin.jobs.updateError'))
} finally {
setIsSaving(false)
}
}
const statusColor = (status: string) => {
switch (status) {
case "open": return "default"
case "draft": return "secondary"
case "closed": case "paused": return "outline"
default: return "outline"
}
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<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" />
{t('admin.jobs.newJob')}
</Button>
</Link>
</div>
<ConfirmModal
isOpen={deleteConfirmDialog.isOpen}
onClose={() => setDeleteConfirmDialog({ isOpen: false, jobId: null })}
onConfirm={confirmDeleteJob}
title={t('admin.jobs.deleteConfirm')}
description="Esta ação não pode ser desfeita e irá excluir a vaga permanentemente."
/>
{/* View Job Dialog */}
<Dialog open={isViewDialogOpen} onOpenChange={setIsViewDialogOpen}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('admin.jobs.details.title')}</DialogTitle>
<DialogDescription>{t('admin.jobs.details.description')}</DialogDescription>
</DialogHeader>
{selectedJob && (
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-muted-foreground text-xs">Título</Label>
<p className="font-medium">{selectedJob.title}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">{t('admin.jobs.table.company')}</Label>
<p className="font-medium">{selectedJob.companyName}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">{t('admin.jobs.table.status')}</Label>
<Badge variant={statusColor(selectedJob.status)}>{selectedJob.status}</Badge>
</div>
<div>
<Label className="text-muted-foreground text-xs">{t('admin.jobs.table.applications')}</Label>
<p className="font-medium">{selectedJob.applicationsCount ?? 0}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">Tipo</Label>
<p className="text-sm">{selectedJob.employmentType ?? "-"}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">Modalidade</Label>
<p className="text-sm">{selectedJob.workMode ?? "-"}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">Localização</Label>
<p className="text-sm">{selectedJob.location ?? "-"}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">Salário</Label>
<p className="text-sm">
{selectedJob.salaryNegotiable
? "Negociável"
: selectedJob.salaryMin
? `${selectedJob.currency ?? ""} ${selectedJob.salaryMin}${selectedJob.salaryMax ? ` ${selectedJob.salaryMax}` : ""}`
: "-"}
</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">Idioma</Label>
<p className="text-sm">{selectedJob.languageLevel || "-"}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">Visto</Label>
<p className="text-sm">{selectedJob.visaSupport ? "Sim" : "Não"}</p>
</div>
</div>
<div>
<Label className="text-muted-foreground text-xs">{t('admin.jobs.details.description')}</Label>
<div className="mt-1 p-3 bg-muted rounded-md text-sm whitespace-pre-wrap max-h-48 overflow-y-auto">
{selectedJob.description || "-"}
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setIsViewDialogOpen(false)}>{t('admin.jobs.details.close')}</Button>
{selectedJob && (
<Button onClick={() => { setIsViewDialogOpen(false); handleEditJob(selectedJob) }}>
<Edit className="h-4 w-4 mr-2" /> Editar
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Job Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('admin.jobs.edit.title')}</DialogTitle>
<DialogDescription>{t('admin.jobs.edit.subtitle')}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Title + Status */}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 grid gap-2">
<Label htmlFor="edit-title">Título *</Label>
<Input
id="edit-title"
value={editForm.title}
onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-status">Status</Label>
<Select value={editForm.status} onValueChange={(v) => setEditForm({ ...editForm, status: v })}>
<SelectTrigger id="edit-status"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="draft">Rascunho</SelectItem>
<SelectItem value="review">Em revisão</SelectItem>
<SelectItem value="open">Aberta</SelectItem>
<SelectItem value="paused">Pausada</SelectItem>
<SelectItem value="closed">Fechada</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-location">Localização</Label>
<Input
id="edit-location"
value={editForm.location}
onChange={(e) => setEditForm({ ...editForm, location: e.target.value })}
placeholder="Ex: Tokyo, Japan"
/>
</div>
</div>
{/* Employment + WorkMode */}
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>Tipo de contrato</Label>
<Select value={editForm.employmentType} onValueChange={(v) => setEditForm({ ...editForm, employmentType: v })}>
<SelectTrigger><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectContent>
<SelectItem value="full-time">CLT / Full-time</SelectItem>
<SelectItem value="part-time">Part-time</SelectItem>
<SelectItem value="contract">PJ / Contract</SelectItem>
<SelectItem value="dispatch">Dispatch</SelectItem>
<SelectItem value="temporary">Temporário</SelectItem>
<SelectItem value="training">Estágio / Training</SelectItem>
<SelectItem value="voluntary">Voluntário</SelectItem>
<SelectItem value="permanent">Efetivo</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Modalidade</Label>
<Select value={editForm.workMode} onValueChange={(v) => setEditForm({ ...editForm, workMode: v })}>
<SelectTrigger><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectContent>
<SelectItem value="onsite">Presencial</SelectItem>
<SelectItem value="hybrid">Híbrido</SelectItem>
<SelectItem value="remote">Remoto</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Salary */}
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>Moeda</Label>
<Select value={editForm.currency} onValueChange={(v) => setEditForm({ ...editForm, currency: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{["BRL", "USD", "EUR", "GBP", "JPY", "CNY", "AED", "CAD", "AUD", "CHF"].map(c => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Periodicidade</Label>
<Select value={editForm.salaryType} onValueChange={(v) => setEditForm({ ...editForm, salaryType: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="hourly">Por hora</SelectItem>
<SelectItem value="daily">Por dia</SelectItem>
<SelectItem value="weekly">Por semana</SelectItem>
<SelectItem value="monthly">Mensal</SelectItem>
<SelectItem value="yearly">Anual</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Salário mínimo</Label>
<Input
type="number"
value={editForm.salaryMin}
onChange={(e) => setEditForm({ ...editForm, salaryMin: e.target.value })}
placeholder="0"
/>
</div>
<div className="grid gap-2">
<Label>Salário máximo</Label>
<Input
type="number"
value={editForm.salaryMax}
onChange={(e) => setEditForm({ ...editForm, salaryMax: e.target.value })}
placeholder="0"
/>
</div>
</div>
{/* Language + WorkingHours */}
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>Nível de idioma</Label>
<Select value={editForm.languageLevel} onValueChange={(v) => setEditForm({ ...editForm, languageLevel: v })}>
<SelectTrigger><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">Nenhum</SelectItem>
<SelectItem value="beginner">Iniciante</SelectItem>
<SelectItem value="N5">JLPT N5</SelectItem>
<SelectItem value="N4">JLPT N4</SelectItem>
<SelectItem value="N3">JLPT N3</SelectItem>
<SelectItem value="N2">JLPT N2</SelectItem>
<SelectItem value="N1">JLPT N1</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Carga horária</Label>
<Input
value={editForm.workingHours}
onChange={(e) => setEditForm({ ...editForm, workingHours: e.target.value })}
placeholder="Ex: 40h/semana"
/>
</div>
</div>
{/* Toggles */}
<div className="flex items-center gap-6 border rounded-md p-4">
<div className="flex items-center gap-2">
<Switch
checked={editForm.salaryNegotiable}
onCheckedChange={(v) => setEditForm({ ...editForm, salaryNegotiable: v })}
id="edit-negotiable"
/>
<Label htmlFor="edit-negotiable">Salário negociável</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={editForm.visaSupport}
onCheckedChange={(v) => setEditForm({ ...editForm, visaSupport: v })}
id="edit-visa"
/>
<Label htmlFor="edit-visa">Suporte de visto</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={editForm.isFeatured}
onCheckedChange={(v) => setEditForm({ ...editForm, isFeatured: v })}
id="edit-featured"
/>
<Label htmlFor="edit-featured">Destaque</Label>
</div>
</div>
{/* Description */}
<div className="grid gap-2">
<Label htmlFor="edit-desc">Descrição</Label>
<Textarea
id="edit-desc"
rows={5}
value={editForm.description}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>{t('admin.jobs.edit.cancel')}</Button>
<Button onClick={handleSaveEdit} disabled={isSaving}>
{isSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{t('admin.jobs.edit.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-3">
<CardDescription>{t('admin.jobs.stats.total')}</CardDescription>
<CardTitle className="text-3xl">{totalJobs}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>{t('admin.jobs.stats.active')}</CardDescription>
<CardTitle className="text-3xl">{activeJobs}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>{t('admin.jobs.stats.applications')}</CardDescription>
<CardTitle className="text-3xl">{totalApplications}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>{t('admin.jobs.stats.conversion')}</CardDescription>
<CardTitle className="text-3xl">
{jobs.length > 0 ? Math.round((activeJobs / jobs.length) * 100) : 0}%
</CardTitle>
</CardHeader>
</Card>
</div>
{/* Search + 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={t('admin.jobs.searchPlaceholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('admin.jobs.table.role')}</TableHead>
<TableHead>{t('admin.jobs.table.company')}</TableHead>
<TableHead>Tipo / Modalidade</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={6} className="text-center text-muted-foreground">
{t('admin.jobs.table.loading')}
</TableCell>
</TableRow>
) : errorMessage ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-destructive">{errorMessage}</TableCell>
</TableRow>
) : filteredJobs.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
{t('admin.jobs.table.empty')}
</TableCell>
</TableRow>
) : (
filteredJobs.map((job) => (
<TableRow key={job.id}>
<TableCell className="font-medium">{job.title}</TableCell>
<TableCell>{job.companyName}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{job.employmentType && <span>{job.employmentType}</span>}
{job.workMode && <span className="ml-1">· {job.workMode}</span>}
</TableCell>
<TableCell>{job.applicationsCount ?? 0}</TableCell>
<TableCell>
<Badge variant={statusColor(job.status)}>{job.status}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="icon" onClick={() => handleViewJob(job)}>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleEditJob(job)}>
<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>
<div className="flex items-center justify-between space-x-2 py-4">
<div className="text-sm text-muted-foreground">
Página {page} de {totalPages}
</div>
<div className="space-x-2">
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1 || isLoading}>
<ChevronLeft className="h-4 w-4" /> Anterior
</Button>
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages || isLoading}>
Próxima <ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
)
}