feat(skills): add skill reordering functionality
Implement drag-and-drop alternative for skill reordering with up/down buttons. Also improve skill level type safety and filename generation by including date. refactor(export): update PDF filename format to include date
This commit is contained in:
parent
0ebf5fe3de
commit
44fdc1ce52
@ -10,7 +10,15 @@ export async function exportPdf(cv: CV, endpoint = 'http://localhost:4000/export
|
||||
const response = await axios.post(endpoint, { ...cv, templateId }, { responseType: 'blob' });
|
||||
const contentDisposition = response.headers['content-disposition'] || '';
|
||||
const match = /filename="?([^";]+)"?/i.exec(contentDisposition);
|
||||
const filename = match ? match[1] : `${cv.personal.firstName || 'Candidate'}_${cv.personal.lastName || 'CV'}.pdf`;
|
||||
const filename = match ? match[1] : (() => {
|
||||
const today = new Date();
|
||||
const y = today.getFullYear();
|
||||
const m = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(today.getDate()).padStart(2, '0');
|
||||
const dateStr = `${y}${m}${d}`;
|
||||
const safeLast = (cv.personal.lastName || '').trim() || 'cv';
|
||||
return `cv-${safeLast}-${dateStr}.pdf`;
|
||||
})();
|
||||
|
||||
const blob = new Blob([response.data], { type: 'application/pdf' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
@ -48,7 +56,14 @@ export async function downloadJob(jobId: string, baseUrl = 'http://localhost:400
|
||||
const response = await axios.get(`${baseUrl}/export/download/${jobId}`, { responseType: 'blob' });
|
||||
const contentDisposition = response.headers['content-disposition'] || '';
|
||||
const match = /filename="?([^";]+)"?/i.exec(contentDisposition);
|
||||
const filename = match ? match[1] : `CV.pdf`;
|
||||
const filename = match ? match[1] : (() => {
|
||||
const today = new Date();
|
||||
const y = today.getFullYear();
|
||||
const m = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(today.getDate()).padStart(2, '0');
|
||||
const dateStr = `${y}${m}${d}`;
|
||||
return `cv-cv-${dateStr}.pdf`;
|
||||
})();
|
||||
const blob = new Blob([response.data], { type: 'application/pdf' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
||||
@ -3,10 +3,19 @@ import { useSkillsData, useCvStore } from '../store/cvStore';
|
||||
|
||||
const SkillsEditor: React.FC = () => {
|
||||
const skills = useSkillsData();
|
||||
const { addSkill, updateSkill, removeSkill } = useCvStore();
|
||||
const { addSkill, updateSkill, removeSkill, reorderSkills } = useCvStore();
|
||||
const [newSkillName, setNewSkillName] = useState('');
|
||||
const [newSkillLevel, setNewSkillLevel] = useState<'Beginner' | 'Intermediate' | 'Advanced' | undefined>(undefined);
|
||||
|
||||
const levels = ['Beginner', 'Intermediate', 'Advanced'] as const;
|
||||
type SkillLevel = typeof levels[number] | undefined;
|
||||
const parseLevel = (value: string): SkillLevel => {
|
||||
if (!value) return undefined;
|
||||
return (levels.includes(value as SkillLevel extends undefined ? never : typeof levels[number])
|
||||
? (value as typeof levels[number])
|
||||
: undefined);
|
||||
};
|
||||
|
||||
const handleAddSkill = () => {
|
||||
const name = newSkillName.trim();
|
||||
if (!name) return;
|
||||
@ -34,7 +43,7 @@ const SkillsEditor: React.FC = () => {
|
||||
<label className="block text-sm font-medium text-gray-700">Level (optional)</label>
|
||||
<select
|
||||
value={newSkillLevel || ''}
|
||||
onChange={(e) => setNewSkillLevel((e.target.value as any) || undefined)}
|
||||
onChange={(e) => setNewSkillLevel(parseLevel(e.target.value))}
|
||||
className="px-3 py-2 border rounded-md border-gray-300"
|
||||
>
|
||||
<option value="">None</option>
|
||||
@ -57,7 +66,7 @@ const SkillsEditor: React.FC = () => {
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{skills.map((skill) => (
|
||||
{skills.map((skill, idx) => (
|
||||
<div key={skill.id} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
@ -67,7 +76,7 @@ const SkillsEditor: React.FC = () => {
|
||||
/>
|
||||
<select
|
||||
value={skill.level || ''}
|
||||
onChange={(e) => updateSkill(skill.id, skill.name, (e.target.value as any) || undefined)}
|
||||
onChange={(e) => updateSkill(skill.id, skill.name, parseLevel(e.target.value))}
|
||||
className="px-3 py-2 border rounded-md border-gray-300"
|
||||
>
|
||||
<option value="">None</option>
|
||||
@ -75,6 +84,26 @@ const SkillsEditor: React.FC = () => {
|
||||
<option value="Intermediate">Intermediate</option>
|
||||
<option value="Advanced">Advanced</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reorderSkills(idx, Math.max(0, idx - 1))}
|
||||
disabled={idx === 0}
|
||||
className={`px-2 py-1 rounded-md text-sm ${idx === 0 ? 'bg-gray-200 text-gray-400' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
|
||||
title="Move up"
|
||||
>
|
||||
Up
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reorderSkills(idx, Math.min(skills.length - 1, idx + 1))}
|
||||
disabled={idx === skills.length - 1}
|
||||
className={`px-2 py-1 rounded-md text-sm ${idx === skills.length - 1 ? 'bg-gray-200 text-gray-400' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
|
||||
title="Move down"
|
||||
>
|
||||
Down
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSkill(skill.id)}
|
||||
|
||||
@ -44,6 +44,7 @@ interface CvState {
|
||||
addSkill: (name: string, level?: z.infer<typeof CvSchema>['skills'][0]['level']) => void;
|
||||
updateSkill: (id: string, name: string, level?: z.infer<typeof CvSchema>['skills'][0]['level']) => void;
|
||||
removeSkill: (id: string) => void;
|
||||
reorderSkills: (fromIndex: number, toIndex: number) => void;
|
||||
|
||||
// Navigation
|
||||
setActiveStep: (step: EditorStep) => void;
|
||||
@ -260,6 +261,17 @@ export const useCvStore = create<CvState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
reorderSkills: (fromIndex, toIndex) => {
|
||||
const { cv } = get();
|
||||
const skills = [...cv.skills];
|
||||
const [removed] = skills.splice(fromIndex, 1);
|
||||
skills.splice(toIndex, 0, removed);
|
||||
set({
|
||||
cv: { ...cv, skills },
|
||||
isDirty: true
|
||||
});
|
||||
},
|
||||
|
||||
// Navigation
|
||||
setActiveStep: (activeStep) => set({ activeStep }),
|
||||
|
||||
|
||||
@ -204,9 +204,14 @@ app.post('/export/pdf', async (req, res) => {
|
||||
}) : ''
|
||||
};
|
||||
|
||||
const firstName = (sanitizedCv.personal && sanitizedCv.personal.firstName) ? sanitizedCv.personal.firstName : 'Candidate';
|
||||
const lastName = (sanitizedCv.personal && sanitizedCv.personal.lastName) ? sanitizedCv.personal.lastName : 'CV';
|
||||
const filename = `${firstName}_${lastName}_CV.pdf`.replace(/\s+/g, '_');
|
||||
const today = new Date();
|
||||
const y = today.getFullYear();
|
||||
const m = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(today.getDate()).padStart(2, '0');
|
||||
const dateStr = `${y}${m}${d}`;
|
||||
const rawLast = (sanitizedCv.personal && sanitizedCv.personal.lastName) ? sanitizedCv.personal.lastName : '';
|
||||
const safeLast = rawLast.trim() ? rawLast.trim() : 'cv';
|
||||
const filename = `cv-${safeLast}-${dateStr}.pdf`.replace(/\s+/g, '_');
|
||||
|
||||
if (asyncFlag) {
|
||||
const jobId = jobs.create({ status: 'queued', filename, error: null, buffer: null, templateId });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user