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 response = await axios.post(endpoint, { ...cv, templateId }, { responseType: 'blob' });
|
||||||
const contentDisposition = response.headers['content-disposition'] || '';
|
const contentDisposition = response.headers['content-disposition'] || '';
|
||||||
const match = /filename="?([^";]+)"?/i.exec(contentDisposition);
|
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 blob = new Blob([response.data], { type: 'application/pdf' });
|
||||||
const url = window.URL.createObjectURL(blob);
|
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 response = await axios.get(`${baseUrl}/export/download/${jobId}`, { responseType: 'blob' });
|
||||||
const contentDisposition = response.headers['content-disposition'] || '';
|
const contentDisposition = response.headers['content-disposition'] || '';
|
||||||
const match = /filename="?([^";]+)"?/i.exec(contentDisposition);
|
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 blob = new Blob([response.data], { type: 'application/pdf' });
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
|
|||||||
@ -3,10 +3,19 @@ import { useSkillsData, useCvStore } from '../store/cvStore';
|
|||||||
|
|
||||||
const SkillsEditor: React.FC = () => {
|
const SkillsEditor: React.FC = () => {
|
||||||
const skills = useSkillsData();
|
const skills = useSkillsData();
|
||||||
const { addSkill, updateSkill, removeSkill } = useCvStore();
|
const { addSkill, updateSkill, removeSkill, reorderSkills } = useCvStore();
|
||||||
const [newSkillName, setNewSkillName] = useState('');
|
const [newSkillName, setNewSkillName] = useState('');
|
||||||
const [newSkillLevel, setNewSkillLevel] = useState<'Beginner' | 'Intermediate' | 'Advanced' | undefined>(undefined);
|
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 handleAddSkill = () => {
|
||||||
const name = newSkillName.trim();
|
const name = newSkillName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
@ -34,7 +43,7 @@ const SkillsEditor: React.FC = () => {
|
|||||||
<label className="block text-sm font-medium text-gray-700">Level (optional)</label>
|
<label className="block text-sm font-medium text-gray-700">Level (optional)</label>
|
||||||
<select
|
<select
|
||||||
value={newSkillLevel || ''}
|
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"
|
className="px-3 py-2 border rounded-md border-gray-300"
|
||||||
>
|
>
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
@ -57,7 +66,7 @@ const SkillsEditor: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{skills.map((skill) => (
|
{skills.map((skill, idx) => (
|
||||||
<div key={skill.id} className="flex items-center gap-2">
|
<div key={skill.id} className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -67,7 +76,7 @@ const SkillsEditor: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={skill.level || ''}
|
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"
|
className="px-3 py-2 border rounded-md border-gray-300"
|
||||||
>
|
>
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
@ -75,6 +84,26 @@ const SkillsEditor: React.FC = () => {
|
|||||||
<option value="Intermediate">Intermediate</option>
|
<option value="Intermediate">Intermediate</option>
|
||||||
<option value="Advanced">Advanced</option>
|
<option value="Advanced">Advanced</option>
|
||||||
</select>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeSkill(skill.id)}
|
onClick={() => removeSkill(skill.id)}
|
||||||
|
|||||||
@ -44,6 +44,7 @@ interface CvState {
|
|||||||
addSkill: (name: string, level?: z.infer<typeof CvSchema>['skills'][0]['level']) => void;
|
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;
|
updateSkill: (id: string, name: string, level?: z.infer<typeof CvSchema>['skills'][0]['level']) => void;
|
||||||
removeSkill: (id: string) => void;
|
removeSkill: (id: string) => void;
|
||||||
|
reorderSkills: (fromIndex: number, toIndex: number) => void;
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
setActiveStep: (step: EditorStep) => void;
|
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
|
// Navigation
|
||||||
setActiveStep: (activeStep) => set({ activeStep }),
|
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 today = new Date();
|
||||||
const lastName = (sanitizedCv.personal && sanitizedCv.personal.lastName) ? sanitizedCv.personal.lastName : 'CV';
|
const y = today.getFullYear();
|
||||||
const filename = `${firstName}_${lastName}_CV.pdf`.replace(/\s+/g, '_');
|
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) {
|
if (asyncFlag) {
|
||||||
const jobId = jobs.create({ status: 'queued', filename, error: null, buffer: null, templateId });
|
const jobId = jobs.create({ status: 'queued', filename, error: null, buffer: null, templateId });
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user