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:
geulah 2025-10-06 01:26:19 +01:00
parent 0ebf5fe3de
commit 44fdc1ce52
4 changed files with 70 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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