feat: add currency, salary period, and rich text description
Frontend: - Added currency selector (BRL, USD, EUR, JPY, GBP, CNY, AED, CAD, AUD, CHF) - Added salary period dropdown (hourly, daily, weekly, monthly, yearly) - Created RichTextEditor component for job descriptions (Bold, Lists, Alignment) - Updated confirmation step to display currency symbol and period label Backend: - JobService now persists currency in job creation - Extended currency validation in DTOs Seeder: - Already includes currency in job insertion
This commit is contained in:
parent
91e4417c95
commit
cca951ca23
4 changed files with 178 additions and 19 deletions
|
|
@ -8,7 +8,7 @@ type CreateJobRequest struct {
|
||||||
SalaryMin *float64 `json:"salaryMin,omitempty"`
|
SalaryMin *float64 `json:"salaryMin,omitempty"`
|
||||||
SalaryMax *float64 `json:"salaryMax,omitempty"`
|
SalaryMax *float64 `json:"salaryMax,omitempty"`
|
||||||
SalaryType *string `json:"salaryType,omitempty" validate:"omitempty,oneof=hourly daily weekly monthly yearly"`
|
SalaryType *string `json:"salaryType,omitempty" validate:"omitempty,oneof=hourly daily weekly monthly yearly"`
|
||||||
Currency *string `json:"currency,omitempty" validate:"omitempty,oneof=BRL USD EUR GBP JPY"`
|
Currency *string `json:"currency,omitempty" validate:"omitempty,oneof=BRL USD EUR GBP JPY CNY AED CAD AUD CHF"`
|
||||||
SalaryNegotiable bool `json:"salaryNegotiable"`
|
SalaryNegotiable bool `json:"salaryNegotiable"`
|
||||||
EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract temporary training voluntary permanent"`
|
EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract temporary training voluntary permanent"`
|
||||||
WorkingHours *string `json:"workingHours,omitempty"`
|
WorkingHours *string `json:"workingHours,omitempty"`
|
||||||
|
|
@ -29,7 +29,7 @@ type UpdateJobRequest struct {
|
||||||
SalaryMin *float64 `json:"salaryMin,omitempty"`
|
SalaryMin *float64 `json:"salaryMin,omitempty"`
|
||||||
SalaryMax *float64 `json:"salaryMax,omitempty"`
|
SalaryMax *float64 `json:"salaryMax,omitempty"`
|
||||||
SalaryType *string `json:"salaryType,omitempty" validate:"omitempty,oneof=hourly daily weekly monthly yearly"`
|
SalaryType *string `json:"salaryType,omitempty" validate:"omitempty,oneof=hourly daily weekly monthly yearly"`
|
||||||
Currency *string `json:"currency,omitempty" validate:"omitempty,oneof=BRL USD EUR GBP JPY"`
|
Currency *string `json:"currency,omitempty" validate:"omitempty,oneof=BRL USD EUR GBP JPY CNY AED CAD AUD CHF"`
|
||||||
SalaryNegotiable *bool `json:"salaryNegotiable,omitempty"`
|
SalaryNegotiable *bool `json:"salaryNegotiable,omitempty"`
|
||||||
EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract temporary training voluntary permanent"`
|
EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract temporary training voluntary permanent"`
|
||||||
WorkingHours *string `json:"workingHours,omitempty"`
|
WorkingHours *string `json:"workingHours,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,10 @@ func (s *JobService) CreateJob(req dto.CreateJobRequest, createdBy string) (*mod
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO jobs (
|
INSERT INTO jobs (
|
||||||
company_id, created_by, title, description, salary_min, salary_max, salary_type,
|
company_id, created_by, title, description, salary_min, salary_max, salary_type, currency,
|
||||||
employment_type, working_hours, location, region_id, city_id,
|
employment_type, working_hours, location, region_id, city_id,
|
||||||
requirements, benefits, visa_support, language_level, status, created_at, updated_at, salary_negotiable
|
requirements, benefits, visa_support, language_level, status, created_at, updated_at, salary_negotiable
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
|
||||||
RETURNING id, created_at, updated_at
|
RETURNING id, created_at, updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
@ -39,6 +39,7 @@ func (s *JobService) CreateJob(req dto.CreateJobRequest, createdBy string) (*mod
|
||||||
SalaryMin: req.SalaryMin,
|
SalaryMin: req.SalaryMin,
|
||||||
SalaryMax: req.SalaryMax,
|
SalaryMax: req.SalaryMax,
|
||||||
SalaryType: req.SalaryType,
|
SalaryType: req.SalaryType,
|
||||||
|
Currency: req.Currency,
|
||||||
SalaryNegotiable: req.SalaryNegotiable,
|
SalaryNegotiable: req.SalaryNegotiable,
|
||||||
EmploymentType: req.EmploymentType,
|
EmploymentType: req.EmploymentType,
|
||||||
WorkingHours: req.WorkingHours,
|
WorkingHours: req.WorkingHours,
|
||||||
|
|
@ -59,7 +60,7 @@ func (s *JobService) CreateJob(req dto.CreateJobRequest, createdBy string) (*mod
|
||||||
|
|
||||||
err := s.DB.QueryRow(
|
err := s.DB.QueryRow(
|
||||||
query,
|
query,
|
||||||
job.CompanyID, job.CreatedBy, job.Title, job.Description, job.SalaryMin, job.SalaryMax, job.SalaryType,
|
job.CompanyID, job.CreatedBy, job.Title, job.Description, job.SalaryMin, job.SalaryMax, job.SalaryType, job.Currency,
|
||||||
job.EmploymentType, job.WorkingHours, job.Location, job.RegionID, job.CityID,
|
job.EmploymentType, job.WorkingHours, job.Location, job.RegionID, job.CityID,
|
||||||
job.Requirements, job.Benefits, job.VisaSupport, job.LanguageLevel, job.Status, job.CreatedAt, job.UpdatedAt, job.SalaryNegotiable,
|
job.Requirements, job.Benefits, job.VisaSupport, job.LanguageLevel, job.Status, job.CreatedAt, job.UpdatedAt, job.SalaryNegotiable,
|
||||||
).Scan(&job.ID, &job.CreatedAt, &job.UpdatedAt)
|
).Scan(&job.ID, &job.CreatedAt, &job.UpdatedAt)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
Eye, EyeOff, Globe
|
Eye, EyeOff, Globe
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { LocationPicker } from "@/components/location-picker";
|
import { LocationPicker } from "@/components/location-picker";
|
||||||
|
import { RichTextEditor } from "@/components/rich-text-editor";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
// Common Country Codes
|
// Common Country Codes
|
||||||
|
|
@ -37,6 +38,24 @@ const COUNTRY_CODES = [
|
||||||
{ code: "+51", country: "Peru (PE)" },
|
{ code: "+51", country: "Peru (PE)" },
|
||||||
].sort((a, b) => a.country.localeCompare(b.country));
|
].sort((a, b) => a.country.localeCompare(b.country));
|
||||||
|
|
||||||
|
// Currency symbol helper
|
||||||
|
const getCurrencySymbol = (code: string): string => {
|
||||||
|
const symbols: Record<string, string> = {
|
||||||
|
'BRL': 'R$', 'USD': '$', 'EUR': '€', 'JPY': '¥', 'GBP': '£',
|
||||||
|
'CNY': '¥', 'AED': 'د.إ', 'CAD': 'C$', 'AUD': 'A$', 'CHF': 'Fr'
|
||||||
|
};
|
||||||
|
return symbols[code] || code;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Salary period label helper
|
||||||
|
const getSalaryPeriodLabel = (type: string): string => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
'hourly': '/hora', 'daily': '/dia', 'weekly': '/semana',
|
||||||
|
'monthly': '/mês', 'yearly': '/ano'
|
||||||
|
};
|
||||||
|
return labels[type] || '';
|
||||||
|
};
|
||||||
|
|
||||||
export default function PostJobPage() {
|
export default function PostJobPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [step, setStep] = useState<1 | 2 | 3>(1);
|
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||||
|
|
@ -63,6 +82,8 @@ export default function PostJobPage() {
|
||||||
salaryMin: "",
|
salaryMin: "",
|
||||||
salaryMax: "",
|
salaryMax: "",
|
||||||
salaryFixed: "", // For fixed salary mode
|
salaryFixed: "", // For fixed salary mode
|
||||||
|
currency: "BRL", // Default currency
|
||||||
|
salaryType: "monthly", // Default salary period
|
||||||
employmentType: "",
|
employmentType: "",
|
||||||
workMode: "remote",
|
workMode: "remote",
|
||||||
workingHours: "",
|
workingHours: "",
|
||||||
|
|
@ -144,6 +165,8 @@ export default function PostJobPage() {
|
||||||
// Salary logic: if negotiable, send null values
|
// Salary logic: if negotiable, send null values
|
||||||
salaryMin: job.salaryNegotiable ? null : (salaryMode === 'fixed' ? (job.salaryFixed ? parseInt(job.salaryFixed) : null) : (job.salaryMin ? parseInt(job.salaryMin) : null)),
|
salaryMin: job.salaryNegotiable ? null : (salaryMode === 'fixed' ? (job.salaryFixed ? parseInt(job.salaryFixed) : null) : (job.salaryMin ? parseInt(job.salaryMin) : null)),
|
||||||
salaryMax: job.salaryNegotiable ? null : (salaryMode === 'fixed' ? (job.salaryFixed ? parseInt(job.salaryFixed) : null) : (job.salaryMax ? parseInt(job.salaryMax) : null)),
|
salaryMax: job.salaryNegotiable ? null : (salaryMode === 'fixed' ? (job.salaryFixed ? parseInt(job.salaryFixed) : null) : (job.salaryMax ? parseInt(job.salaryMax) : null)),
|
||||||
|
salaryType: job.salaryNegotiable ? null : job.salaryType,
|
||||||
|
currency: job.salaryNegotiable ? null : job.currency,
|
||||||
salaryNegotiable: job.salaryNegotiable,
|
salaryNegotiable: job.salaryNegotiable,
|
||||||
employmentType: job.employmentType || null,
|
employmentType: job.employmentType || null,
|
||||||
workingHours: job.workingHours || null,
|
workingHours: job.workingHours || null,
|
||||||
|
|
@ -348,12 +371,12 @@ export default function PostJobPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Descrição *</Label>
|
<Label>Descrição da Vaga *</Label>
|
||||||
<Textarea
|
<RichTextEditor
|
||||||
value={job.description}
|
value={job.description}
|
||||||
onChange={(e) => setJob({ ...job, description: e.target.value })}
|
onChange={(val) => setJob({ ...job, description: val })}
|
||||||
placeholder="Descreva as responsabilidades, requisitos e benefícios..."
|
placeholder="Descreva as responsabilidades, requisitos e benefícios..."
|
||||||
rows={5}
|
minHeight="150px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -382,17 +405,53 @@ export default function PostJobPage() {
|
||||||
|
|
||||||
{!job.salaryNegotiable && (
|
{!job.salaryNegotiable && (
|
||||||
<>
|
<>
|
||||||
{salaryMode === 'fixed' ? (
|
{/* Currency and Period Row */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Label className="text-xs text-muted-foreground">Moeda</Label>
|
||||||
type="number"
|
<select
|
||||||
value={job.salaryFixed}
|
value={job.currency}
|
||||||
onChange={(e) => setJob({ ...job, salaryFixed: e.target.value })}
|
onChange={(e) => setJob({ ...job, currency: e.target.value })}
|
||||||
placeholder="Valor"
|
className="w-full px-3 py-2 border rounded-lg bg-background text-sm"
|
||||||
/>
|
>
|
||||||
|
<option value="BRL">R$ (BRL)</option>
|
||||||
|
<option value="USD">$ (USD)</option>
|
||||||
|
<option value="EUR">€ (EUR)</option>
|
||||||
|
<option value="JPY">¥ (JPY)</option>
|
||||||
|
<option value="GBP">£ (GBP)</option>
|
||||||
|
<option value="CNY">¥ (CNY)</option>
|
||||||
|
<option value="AED">د.إ (AED)</option>
|
||||||
|
<option value="CAD">$ (CAD)</option>
|
||||||
|
<option value="AUD">$ (AUD)</option>
|
||||||
|
<option value="CHF">Fr (CHF)</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">Período</Label>
|
||||||
|
<select
|
||||||
|
value={job.salaryType}
|
||||||
|
onChange={(e) => setJob({ ...job, salaryType: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg bg-background text-sm"
|
||||||
|
>
|
||||||
|
<option value="hourly">por hora</option>
|
||||||
|
<option value="daily">por dia</option>
|
||||||
|
<option value="weekly">por semana</option>
|
||||||
|
<option value="monthly">por mês</option>
|
||||||
|
<option value="yearly">por ano</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Salary Value(s) */}
|
||||||
|
{salaryMode === 'fixed' ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={job.salaryFixed}
|
||||||
|
onChange={(e) => setJob({ ...job, salaryFixed: e.target.value })}
|
||||||
|
placeholder="Valor"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={job.salaryMin}
|
value={job.salaryMin}
|
||||||
|
|
@ -493,8 +552,8 @@ export default function PostJobPage() {
|
||||||
job.salaryNegotiable
|
job.salaryNegotiable
|
||||||
? "Candidato envia proposta"
|
? "Candidato envia proposta"
|
||||||
: salaryMode === 'fixed'
|
: salaryMode === 'fixed'
|
||||||
? (job.salaryFixed ? `R$ ${job.salaryFixed}` : "A combinar")
|
? (job.salaryFixed ? `${getCurrencySymbol(job.currency)} ${job.salaryFixed} ${getSalaryPeriodLabel(job.salaryType)}` : "A combinar")
|
||||||
: (job.salaryMin && job.salaryMax ? `R$ ${job.salaryMin} - R$ ${job.salaryMax}` : "A combinar")
|
: (job.salaryMin && job.salaryMax ? `${getCurrencySymbol(job.currency)} ${job.salaryMin} - ${job.salaryMax} ${getSalaryPeriodLabel(job.salaryType)}` : "A combinar")
|
||||||
}</p>
|
}</p>
|
||||||
<p><strong>Tipo:</strong> {job.employmentType || "Qualquer"} / {job.workingHours === 'full-time' ? 'Integral' : job.workingHours === 'part-time' ? 'Meio Período' : 'Qualquer'} / {job.workMode}</p>
|
<p><strong>Tipo:</strong> {job.employmentType || "Qualquer"} / {job.workingHours === 'full-time' ? 'Integral' : job.workingHours === 'part-time' ? 'Meio Período' : 'Qualquer'} / {job.workMode}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
99
frontend/src/components/rich-text-editor.tsx
Normal file
99
frontend/src/components/rich-text-editor.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback } from "react";
|
||||||
|
import { Bold, List, ListOrdered, AlignLeft, AlignRight } from "lucide-react";
|
||||||
|
|
||||||
|
interface RichTextEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
minHeight?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RichTextEditor({ value, onChange, placeholder, minHeight = "200px" }: RichTextEditorProps) {
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
|
const execCommand = useCallback((command: string, value?: string) => {
|
||||||
|
document.execCommand(command, false, value);
|
||||||
|
if (editorRef.current) {
|
||||||
|
onChange(editorRef.current.innerHTML);
|
||||||
|
}
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
const handleInput = () => {
|
||||||
|
if (editorRef.current) {
|
||||||
|
onChange(editorRef.current.innerHTML);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = (e: React.ClipboardEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = e.clipboardData.getData('text/plain');
|
||||||
|
document.execCommand('insertText', false, text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToolbarButton = ({ onClick, active, children, title }: {
|
||||||
|
onClick: () => void;
|
||||||
|
active?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
className={`p-1.5 rounded hover:bg-muted transition-colors ${active ? 'bg-muted text-primary' : 'text-muted-foreground'}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`border rounded-lg overflow-hidden ${isFocused ? 'ring-2 ring-ring ring-offset-2' : ''}`}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-1 p-2 border-b bg-muted/30">
|
||||||
|
<ToolbarButton onClick={() => execCommand('bold')} title="Negrito">
|
||||||
|
<Bold className="h-4 w-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<div className="w-px h-4 bg-border mx-1" />
|
||||||
|
<ToolbarButton onClick={() => execCommand('insertUnorderedList')} title="Lista com marcadores">
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton onClick={() => execCommand('insertOrderedList')} title="Lista numerada">
|
||||||
|
<ListOrdered className="h-4 w-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<div className="w-px h-4 bg-border mx-1" />
|
||||||
|
<ToolbarButton onClick={() => execCommand('justifyLeft')} title="Alinhar à esquerda">
|
||||||
|
<AlignLeft className="h-4 w-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton onClick={() => execCommand('justifyRight')} title="Alinhar à direita">
|
||||||
|
<AlignRight className="h-4 w-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor Area */}
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
contentEditable
|
||||||
|
onInput={handleInput}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={() => setIsFocused(false)}
|
||||||
|
className="p-3 outline-none bg-background"
|
||||||
|
style={{ minHeight }}
|
||||||
|
data-placeholder={placeholder}
|
||||||
|
dangerouslySetInnerHTML={{ __html: value }}
|
||||||
|
suppressContentEditableWarning
|
||||||
|
/>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
[contenteditable]:empty:before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue