Commit 11d16e08 authored by Anthony Jacob's avatar Anthony Jacob
Browse files

add experience management

parent 040b3b23
Loading
Loading
Loading
Loading
+172 −0
Original line number Diff line number Diff line
'use client';
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { deleteExperience } from "./actions";


export default function ExperienceListClient({ data, enabledLanguage }:
    { data: any, enabledLanguage: any }) {
    const router = useRouter();
    const Experiences = Array.isArray(data?.experiences) ? data.experiences : [];

    const [selectedExperiences, setselectedExperiences] = useState<Record<number, boolean>>({});
    const handleSelect = (id: number, checked: boolean) => {
        setselectedExperiences((prev) => ({ ...prev, [id]: checked }));
    };
    const [showDeleteModal, setShowDeleteModal] = useState(false);
    // Toasts state for multiple simultaneous toasts
    const [toasts, setToasts] = useState<Array<{ id: number; message: string; type: 'success' | 'danger' }>>([]);

    const confirmDelete = async () => {
        const ExperienceIds = Object.keys(selectedExperiences).filter(id => selectedExperiences[Number(id)]);
        if (ExperienceIds.length === 0) {
            console.warn("No Experiences selected for deletion.");
            return;
        }
        setShowDeleteModal(false);
        console.log("Confirmed deletion for Experience IDs:", ExperienceIds);

        try {
            await Promise.all(ExperienceIds.map(id => deleteExperience(Number(id))));
            setToasts(ts => [...ts, { id: Date.now() + Math.random(), message: 'Selected Experience deleted successfully.', type: 'success' }]);

            setselectedExperiences({});
            router.refresh();
        } catch (err) {
            setToasts(ts => [...ts, { id: Date.now() + Math.random(), message: 'Failed to delete selected Experience.', type: 'danger' }]);
            setselectedExperiences({});
        }

    };


    const handleDelete = async () => {
        const ExperienceIds = Object.keys(selectedExperiences).filter(id => selectedExperiences[Number(id)]);
        if (ExperienceIds.length === 0) {
            console.warn("No Experiences selected for deletion.");
            return;
        }
        setShowDeleteModal(true);
        console.log("Selected Experience IDs for deletion:", ExperienceIds);

    };


    // Auto-hide each toast after 3 seconds
    useEffect(() => {
        if (toasts.length > 0) {
            const timers = toasts.map((toast) =>
                setTimeout(() => {
                    setToasts(ts => ts.filter(t => t.id !== toast.id));
                }, 3000)
            );
            return () => timers.forEach(timer => clearTimeout(timer));
        }
    }, [toasts]);

    if (!Array.isArray(Experiences) || Experiences.length === 0) {
        return (
            <div className="container container-fluid d-flex flex-column align-items-center justify-content-center vh-100 experience-list-container">
                <h2 className="text-2xl font-semibold mb-4">Experience</h2>
                <div>No Experience found.</div>
            </div>
        );
    }
    console.log("Experiences:", Experiences);

    return (
        <div className="container container-fluid d-flex flex-column align-items-center justify-content-center vh-100 experience-list-container">

            <h2 className="text-2xl font-semibold mb-4">Experience</h2>
            <table className="table table-striped">
                <thead>
                    <tr>
                        <th></th>
                        <th>ID</th>
                        <th>Title</th>
                        <th>Start Date</th>
                        <th>End Date</th>
                        <th>Edit</th>
                    </tr>
                </thead>
                <tbody>
                    {Experiences && Experiences.map((experience: any) => (
                        <tr key={experience.id}>
                            <td>
                                <input
                                    type="checkbox"
                                    className="form-check-input"
                                    checked={!!selectedExperiences[experience.id]}
                                    onChange={e => handleSelect(experience.id, e.target.checked)}
                                />
                            </td>
                            <td>{experience.id}</td>
                            <td>{
                                experience.translations.find((t: any) => t.language_code === 'fr')?.job_title ||
                                experience.translations[0]?.job_title || ''
                            }</td>
                            <td>{experience.start_date ? new Date(experience.start_date).toISOString().split('T')[0] : ''}</td>
                            <td>{experience.end_date ? new Date(experience.end_date).toISOString().split('T')[0] : ''}</td>
                            <td>
                                <a href={`/Admin/Experience/edit/${experience.id}`} className="btn btn-primary">
                                    Edit
                                </a>
                            </td>
                        </tr>
                    ))}
                </tbody>
            </table>
            {/* Action buttons at the bottom of the grid */}
            <div className="d-flex justify-content-end gap-2 mt-3">
                <button
                    className="btn btn-danger"
                    style={{ borderRadius: "50%", width: 56, height: 56, fontSize: 24 }}
                    title="Delete"
                    onClick={handleDelete}
                    disabled={Object.keys(selectedExperiences).length === 0}
                >
                    <i className="bi bi-trash"></i>
                </button>
                <a href="/Admin/Experience/edit/new" className="btn btn-primary" style={{ borderRadius: "50%", width: 56, height: 56, fontSize: 28 }} title="Add">
                    <i className="bi bi-plus"></i>
                </a>
            </div>
            {/* Delete Confirmation Modal */}
            <div className={`modal fade${showDeleteModal ? ' show d-block' : ''}`} tabIndex={-1} role="dialog" style={{ background: showDeleteModal ? 'rgba(0,0,0,0.5)' : undefined }}>
                <div className="modal-dialog" role="document">
                    <div className="modal-content">
                        <div className="modal-header">
                            <h5 className="modal-title">Confirm Delete</h5>
                            <button type="button" className="btn-close" aria-label="Close" onClick={() => setShowDeleteModal(false)}></button>
                        </div>
                        <div className="modal-body">
                            <p>Are you sure you want to delete {Object.values(selectedExperiences).map(v => !v).length} Experience(s)? This action cannot be undone.</p>
                        </div>
                        <div className="modal-footer">
                            <button type="button" className="btn btn-secondary" onClick={() => setShowDeleteModal(false)}>Cancel</button>
                            <button type="button" className="btn btn-danger" onClick={confirmDelete}>Delete</button>
                        </div>
                    </div>
                </div>
            </div>
            {/* Toast messages (multiple, simultaneous) */}
            <div style={{ position: 'fixed', top: 20, right: 20, zIndex: 2000 }}>
                {toasts.map((toast) => (
                    <div
                        key={toast.id}
                        className={`toast align-items-center text-bg-${toast.type} border-0 mb-2 show`}
                        role="alert"
                        aria-live="assertive"
                        aria-atomic="true"
                        style={{ minWidth: 250 }}
                    >
                        <div className="d-flex">
                            <div className="toast-body">{toast.message}</div>
                            <button type="button" className="btn-close btn-close-white me-2 m-auto" aria-label="Close" onClick={() => setToasts(ts => ts.filter(t => t.id !== toast.id))}></button>
                        </div>
                    </div>
                ))}
            </div>
        </div>
    );
}
 No newline at end of file
+28 −0
Original line number Diff line number Diff line
'use server';
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";


export async function deleteExperience(id: number) {


    console.log("Deleting Experience:", id);
    const cookieStore = cookies();
    const token = (await cookieStore).get("access_token")?.value;

    const res = await fetch(`${process.env.API_URL}/experiences/${id}`, {
        method: "DELETE",
        headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${token}`,
        },
    });

    const data = await res.json();

    if (!res.ok) {
        console.error("Error deleting Experience:", data);
        throw new Error(`Failed to delete Experience: ${JSON.stringify(data)}`);
    }
    revalidatePath("/Admin/Experience");
}
+327 −0
Original line number Diff line number Diff line
"use client";
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Tabs from "@/components/tabs";
import { saveNewExperience, saveExperience, uploadLogo, removeLogo } from "./actions";


export default function ExperienceEditPageClient({ data, enabledLanguage }: { data: any, enabledLanguage: any }) {
    const router = useRouter();

    const [ExperienceID, setExperienceID] = useState(data.id);
    const [start_date, setStartDate] = useState(
        data.id === 0 || !data.start_date ? new Date() : new Date(data.start_date)
    );

    const [end_date, setEndDate] = useState(
        data.id !== 0 && data.end_date ? new Date(data.end_date) : null
    );
    const [error, setError] = useState<string | null>(null);
    const [success, setSuccess] = useState(false);
    const [loading, setLoading] = useState(false);
    const [website, setWebsite] = useState(data.website || "");

    const [logo, setLogo] = useState<string>(data.logo || "");
    const [logoFile, setLogoFile] = useState<File | null>(null);
    const logoBaseUrl = process.env.LOGO_UPLOAD_DIR || "";
    const [logoPreview, setLogoPreview] = useState<string>(data.logo ? `${logoBaseUrl}${data.logo}` : "");
    const [logoIsUpdated, setLogoIsUpdated] = useState<boolean>(false);

    const enabledLanguageList = (enabledLanguage?.languages || []).filter((l: any) => l.enabled);

    // Language-specific translations state
    const [translations, setTranslations] = useState<{ [lang: string]: { job_title: string, job_description: string, job_place: string } }>(() => {
        const initial: { [lang: string]: { job_title: string, job_description: string, job_place: string } } = {};
        if (Array.isArray(data.translations)) {
            data.translations.forEach((t: any) => {
                initial[t.language_code] = {
                    job_title: t.job_title || "",
                    job_description: t.job_description || "",
                    job_place: t.job_place || ""
                };
            });
        }
        enabledLanguageList.forEach((l: any) => {
            if (!initial[l.language_code]) {
                initial[l.language_code] = { job_title: "", job_description: "", job_place: "" };
            }
        });
        return initial;
    });

    const logoInputRef = useRef<HTMLInputElement | null>(null);

    const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        const file = e.target.files?.[0] || null;
        if (file) {
            if (!file.type.startsWith("image/")) {
                setError("Error: file must be an image");
                if (logoIsUpdated) {
                    setLogoFile(null);
                    setLogo("");
                    setLogoPreview("");
                    if (logoInputRef.current) logoInputRef.current.value = "";
                }

                return;
            }
            if (file.size > 2 * 1024 * 1024) { // 2MB
                setError("Error: file too large (max 2MB)");
                if (logoIsUpdated) {
                    setLogoFile(null);
                    setLogo("");
                    setLogoPreview("");
                    if (logoInputRef.current) logoInputRef.current.value = "";
                }
                return;
            }
        }
        setLogoFile(file);
        setLogoIsUpdated(true);
        if (file) {
            setLogoPreview(URL.createObjectURL(file));
        }
    };

    const handleDeleteLogo = () => {

        setLogoPreview("");
        setLogoFile(null);
        setLogo("");
        if (logoInputRef.current) logoInputRef.current.value = "";
        setLogoIsUpdated(true);
    }

    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        setError(null);
        setSuccess(false);
        setLoading(true);

        // Validation: end_date must be set
        if (!start_date) {
            setError("Start date can't be empty");
            setLoading(false);
            return;
        }
        // Validation: title for all enabled languages must be set
        for (const lang of enabledLanguageList) {
            const code = lang.language_code;
            if (!translations[code]?.job_title?.trim()) {
                setError(`Job Title is required for language: ${code}`);
                setLoading(false);
                return;
            }
        }

        try {
            let isNewExperience = ExperienceID === 0;
            let updatedID = ExperienceID;
            if (updatedID === 0) {
                updatedID = await saveNewExperience(end_date, start_date, translations, website);
                setExperienceID(updatedID);


            }
            else {
                await saveExperience(updatedID, end_date, start_date, translations, website);
            }

            //if logofile, the user have selected a new file
            if (logoFile) {
                await uploadLogo(updatedID, logoFile);
            }
            //if data.logo exist but logo is empty, it means the user has removed the logo
            else if (data.logo && !logo) {
                await removeLogo(updatedID);
            }

            if (isNewExperience) {
                setTimeout(() => router.push(`/Admin/Experience/edit/${updatedID}`), 1000);
            }

            setSuccess(true);
        } catch (err: any) {

            setError(err.message || "Failed to update Experience");

        } finally {
            setLoading(false);
        }



        setLoading(false);
    };

    // Always use a public path for SSR, only use blob: for client-side preview
    const getLogoSrc = () => {
        if (logoPreview && logoPreview.startsWith("blob:")) return logoPreview;
        if (logo) return `/uploads/logo/${logo}`;
        return "";
    };

    return <div
        className="container container-fluid d-flex flex-column align-items-center"
        style={{ minHeight: "100vh", width: "100vw", maxWidth: "100vw", overflowY: "auto" }}
    >



        <Link href="/Admin/Experience" className="mb-3 align-self-start text-decoration-none">
            &larr; Back to Experience List
        </Link>
        {ExperienceID !== 0 ? (
            <h2>Edit Experience {ExperienceID}</h2>
        ) : (
            <h2>Add New Experience</h2>
        )}


        <form onSubmit={handleSubmit}>

            <div className="mb-3">
                <label className="form-label" htmlFor="is_valid">
                    Start Date
                </label>
                <input
                    className="form-control"
                    type="date"
                    id="start_date"
                    required
                    value={start_date ? new Date(start_date).toISOString().split('T')[0] : ""}
                    /* @ts-ignore */
                    onChange={e => setStartDate(e.target.value ? new Date(e.target.value) : "")}
                />
            </div>

            <div className="mb-3">
                <label className="form-label" htmlFor="is_valid">
                    End Date
                </label>
                <input
                    className="form-control"
                    type="date"
                    id="end_date"
                    value={end_date ? new Date(end_date).toISOString().split('T')[0] : ""}
                    /* @ts-ignore */
                    onChange={e => setEndDate(e.target.value ? new Date(e.target.value) : "")}
                />
            </div>

            <div className="mb-3">
                <label className="form-label" htmlFor="website">
                    Website
                </label>
                <input
                    className="form-control"
                    type="url"
                    id="website"
                    value={website}
                    onChange={e => setWebsite(e.target.value)}
                />
            </div>


            <Tabs Tabs={enabledLanguageList.map((t: any) => t.language_code)} Id="experienceTab" />
            <div className="tab-content container-fluid d-flex flex-column align-items-center justify-content-center" id="experienceTabContent">
                {enabledLanguageList.map((lang: any, idx: number) => (
                    <div
                        className={`tab-pane fade${idx === 0 ? " show active" : ""}`}
                        id={`${lang.language_code}-tab-pane`}
                        role="tabpanel"
                        aria-labelledby={`${lang.language_code}-tab`}
                        tabIndex={0}
                        key={lang.language_code}
                    >
                        <div className="mb-3">
                            <label htmlFor={`job_title-${lang.language_code}`} className="form-label">Job Title</label>
                            <input
                                id={`job_title-${lang.language_code}`}
                                className="form-control"
                                value={translations[lang.language_code]?.job_title || ""}
                                onChange={e => setTranslations(prev => ({
                                    ...prev,
                                    [lang.language_code]: {
                                        ...prev[lang.language_code],
                                        job_title: e.target.value
                                    }
                                }))}
                            />
                        </div>
                        <div className="mb-3">
                            <label htmlFor={`job_place-${lang.language_code}`} className="form-label">Job Place</label>
                            <input
                                id={`job_place-${lang.language_code}`}
                                className="form-control"
                                value={translations[lang.language_code]?.job_place || ""}
                                onChange={e => setTranslations(prev => ({
                                    ...prev,
                                    [lang.language_code]: {
                                        ...prev[lang.language_code],
                                        job_place: e.target.value
                                    }
                                }))}
                            />
                        </div>
                        <div className="mb-3">
                            <label htmlFor={`job_description-${lang.language_code}`} className="form-label">Job Description</label>
                            <textarea
                                id={`job_description-${lang.language_code}`}
                                className="form-control"
                                value={translations[lang.language_code]?.job_description || ""}
                                onChange={e => setTranslations(prev => ({
                                    ...prev,
                                    [lang.language_code]: {
                                        ...prev[lang.language_code],
                                        job_description: e.target.value
                                    }
                                }))}
                                rows={10}
                            />
                        </div>
                    </div>
                ))}
            </div>
            <div className="mb-3">
                <label className="form-label" htmlFor="logo">
                    Logo
                </label>
                {!getLogoSrc() && (
                    <input
                        className="form-control"
                        type="file"
                        id="logo"
                        accept="image/*"
                        onChange={handleLogoChange}
                        ref={logoInputRef}
                    />
                )}
                {getLogoSrc() && (
                    <div className="mt-2">
                        <img src={getLogoSrc()} alt="Logo Preview" style={{ maxWidth: 200, maxHeight: 200 }} />

                        {logoInputRef && (
                            <button
                                type="button"
                                className="btn btn-secondary mt-2"
                                onClick={handleDeleteLogo}
                                style={{ marginLeft: "10px" }}
                            >
                                Remove Logo
                            </button>
                        )}
                    </div>
                )}
            </div>
            <button className="btn btn-primary" type="submit" disabled={loading} style={{ marginBottom: "10px" }}>
                Save
            </button>
            {error && <div className="alert alert-danger mt-2">{error}</div>}
            {success && <div className="alert alert-success mt-2">Saved!</div>}

        </form >
    </div >

}
 No newline at end of file
+142 −0

File added.

Preview size limit exceeded, changes collapsed.

+67 −0
Original line number Diff line number Diff line
import Link from "next/link";
import { cookies } from "next/headers";
import { RefreshToken } from "@/components/refreshToken";
import { redirect } from "next/navigation";
import ExperienceEditPageClient from "./ExperienceEditPageClient";


export default async function ExperienceEditPage(props: { params: { id?: string | number } }) {

    const { params } = props;
    const experienceId = (await params).id;

    const cookieStore = cookies();
    const token = (await cookieStore).get('access_token')?.value;

    const resLanguage = await fetch(`${process.env.API_URL}/languages/full`, {
        cache: 'no-store',
        headers: {
            Authorization: `Bearer ${token}`,
        },
    });

    const enabledLanguage = resLanguage.ok ? await resLanguage.json() : null;
    console.log("Enabled Language:", enabledLanguage);

    if (enabledLanguage?.msg === "Token has been revoked") {
        //redirect to logout
        return redirect("/logout");
    }


    if (!experienceId || experienceId === "new") {
        // Add mode: pass empty data
        return <>
            <RefreshToken />
            <ExperienceEditPageClient data={{ id: 0 }} enabledLanguage={enabledLanguage} />
        </>;
    }

    const res = await fetch(`${process.env.API_URL}/experiences/${experienceId}`, {
        cache: 'no-store',
        headers: {
            Authorization: `Bearer ${token}`,
        },
    });


    const data = res.ok ? await res.json() : null;
    if (data?.msg === "Token has been revoked") {
        //redirect to logout
        return redirect("/logout");
    }

    if (!data) {
        return <div className="container container-fluid d-flex flex-column align-items-center justify-content-center vh-100">
            <h2>Error loading experience data</h2>
        </div>;
    }

    return (
        <>
            <RefreshToken />
            <ExperienceEditPageClient data={data} enabledLanguage={enabledLanguage} />

        </>
    );
}
 No newline at end of file
Loading