Commit bc3a5e85 authored by Anthony Jacob's avatar Anthony Jacob
Browse files

service management

parent baa26615
Loading
Loading
Loading
Loading
+240 −0
Original line number Diff line number Diff line
'use client';
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { deleteService, updateService } from "./actions";
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';


export default function ServiceListClient({ data, enabledLanguage }:
    { data: any, enabledLanguage: any }) {
    const router = useRouter();
    const Services = Array.isArray(data?.services) ? data.services : [];
    // Sort services by position ascending
    Services.sort((a: any, b: any) => (a.position ?? 0) - (b.position ?? 0));

    const [selectedServices, setselectedServices] = useState<Record<number, boolean>>({});
    const handleSelect = (id: number, checked: boolean) => {
        setselectedServices((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 ServiceIds = Object.keys(selectedServices).filter(id => selectedServices[Number(id)]);
        if (ServiceIds.length === 0) {
            console.warn("No Services selected for deletion.");
            return;
        }
        setShowDeleteModal(false);
        console.log("Confirmed deletion for Service IDs:", ServiceIds);

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

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

    };


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

    };


    // 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]);

    // Local order state for drag-and-drop
    const [orderedServices, setOrderedServices] = useState<any[]>([]);
    useEffect(() => {
        setOrderedServices(Services);
    }, [Services]);

    // DnD-kit setup
    const sensors = useSensors(
        useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
    );
    const handleDragEnd = (event: any) => {
        const { active, over } = event;
        if (!over || active.id !== over.id) {
            const oldIndex = orderedServices.findIndex((s: any) => s.id === active.id);
            const newIndex = orderedServices.findIndex((s: any) => s.id === over.id);
            const newOrder = arrayMove(orderedServices, oldIndex, newIndex);
            setOrderedServices(newOrder);


            console.log(`Moved service ${active.id} from position ${oldIndex + 1} to ${newIndex + 1} in the list`);
            updateService(Number(active.id), newIndex + 1);


        }




    };

    function DraggableRow({ service, children }: { service: any, children: React.ReactNode }) {
        const { attributes, listeners, setNodeRef, transform, transition, setActivatorNodeRef, isDragging } = useSortable({ id: service.id });
        return (
            <tr
                ref={setNodeRef}
                style={{
                    transform: CSS.Transform.toString(transform),
                    transition,
                    opacity: isDragging ? 0.5 : 1,
                    background: isDragging ? '#f0f0f0' : undefined
                }}
                {...attributes}
            >


                {children}
                <td>
                    <i
                        className="bi bi-grip-vertical"
                        title="Drag to reorder"
                        style={{ cursor: "grab", fontSize: "1.2rem" }}
                        ref={setActivatorNodeRef}
                        {...listeners}
                    ></i>
                </td>
            </tr>
        );
    }

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

    return (
        <div className="container container-fluid d-flex flex-column align-items-center justify-content-center vh-100 service-list-container">
            <h2 className="text-2xl font-semibold mb-4">Service</h2>
            <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
                <SortableContext items={orderedServices.map((s: any) => s.id)} strategy={verticalListSortingStrategy}>
                    <table className="table table-striped">
                        <thead>
                            <tr>
                                <th></th>
                                <th>ID</th>
                                <th>Title</th>
                                <th>Position</th>
                                <th></th>
                                <th></th>
                            </tr>
                        </thead>
                        <tbody>
                            {orderedServices.map((service: any) => (
                                <DraggableRow key={service.id} service={service} >

                                    <td>
                                        <input
                                            type="checkbox"
                                            className="form-check-input"
                                            checked={!!selectedServices[service.id]}
                                            onChange={e => handleSelect(service.id, e.target.checked)}
                                        />
                                    </td>
                                    <td>{service.id}</td>
                                    <td>{
                                        service.translations.find((t: any) => t.language_code === 'fr')?.title ||
                                        service.translations[0]?.title || ''
                                    }</td>
                                    <td>{service.position}</td>
                                    <td>
                                        <a href={`/Admin/Service/edit/${service.id}`} className="btn btn-primary">
                                            Edit
                                        </a>
                                    </td>

                                </DraggableRow>
                            ))}
                        </tbody>
                    </table>
                </SortableContext>
            </DndContext>
            {/* 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(selectedServices).length === 0}
                >
                    <i className="bi bi-trash"></i>
                </button>
                <a href="/Admin/Service/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(selectedServices).map(v => !v).length} Service(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
+51 −0
Original line number Diff line number Diff line
'use server';
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";


export async function deleteService(id: number) {
    console.log("deleteService called with ID:", id);

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

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

    const data = await res.json();

    if (!res.ok) {
        console.error("Error deleting Service:", data);
        throw new Error(`Failed to delete Service: ${JSON.stringify(data)}`);
    }
    revalidatePath("/Admin/Service");
}

export async function updateService(ServiceId: number, position: number) {

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

    console.log("updating keyword :", ServiceId);
    console.log(JSON.stringify({ position }));
    const res = await fetch(`${process.env.API_URL}/services/${ServiceId}`, {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({ position }),
    });

    if (!res.ok) {
        const errorText = await res.text();
        throw new Error(`Failed to save keyword: ${errorText}`);
    }
    revalidatePath("/Admin/Service");
}
 No newline at end of file
+277 −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 { saveNewService, saveService, uploadLogo, removeLogo } from "./actions";


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

    const [ServiceID, setServiceID] = useState(data.id);

    const [error, setError] = useState<string | null>(null);
    const [success, setSuccess] = useState(false);
    const [loading, setLoading] = useState(false);

    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 [position, setPosition] = useState<number | null>(typeof data.position === 'number' ? data.position : null);

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

    // Language-specific translations state
    const [translations, setTranslations] = useState<{ [lang: string]: { title: string, content: string } }>(() => {
        const initial: { [lang: string]: { title: string, content: string } } = {};
        if (Array.isArray(data.translations)) {
            data.translations.forEach((t: any) => {
                initial[t.language_code] = {
                    title: t.title || "",
                    content: t.content || ""
                };
            });
        }
        enabledLanguageList.forEach((l: any) => {
            if (!initial[l.language_code]) {
                initial[l.language_code] = { title: "", content: "" };
            }
        });
        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);


        for (const lang of enabledLanguageList) {
            const code = lang.language_code;
            if (!translations[code]?.title?.trim()) {
                setError(`Title is required for language: ${code}`);
                setLoading(false);
                return;
            }

            if (!translations[code]?.content?.trim()) {
                setError(`Content is required for language: ${code}`);
                setLoading(false);
                return;
            }
        }

        try {
            let isNewService = ServiceID === 0;
            let updatedID = ServiceID;
            if (updatedID === 0) {
                updatedID = await saveNewService(position, translations);
                setServiceID(updatedID);
            }
            else {
                await saveService(updatedID, position, translations);
            }

            //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 (isNewService) {
                setTimeout(() => router.push(`/Admin/Service/edit/${updatedID}`), 1000);
            }

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

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

        } 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/Service" className="mb-8 align-self-start text-decoration-none">
            &larr; Back to Service List
        </Link>
        {ServiceID !== 0 ? (
            <h2>Edit Service {ServiceID}</h2>
        ) : (
            <h2>Add New Service</h2>
        )}


        <form onSubmit={handleSubmit}>




            <Tabs Tabs={enabledLanguageList.map((t: any) => t.language_code)} Id="serviceTab" />
            <div className="tab-content container-fluid d-flex flex-column align-items-center justify-content-center" id="serviceTabContent">
                {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-8">
                            <label htmlFor={`title-${lang.language_code}`} className="form-label">Title</label>
                            <input
                                id={`title-${lang.language_code}`}
                                className="form-control"
                                value={translations[lang.language_code]?.title || ""}
                                onChange={e => setTranslations(prev => ({
                                    ...prev,
                                    [lang.language_code]: {
                                        ...prev[lang.language_code],
                                        title: e.target.value
                                    }
                                }))}
                            />
                        </div>
                        <div className="mb-8">
                            <label htmlFor={`content-${lang.language_code}`} className="form-label">Content</label>
                            <textarea
                                id={`content-${lang.language_code}`}
                                className="form-control"
                                value={translations[lang.language_code]?.content || ""}
                                onChange={e => setTranslations(prev => ({
                                    ...prev,
                                    [lang.language_code]: {
                                        ...prev[lang.language_code],
                                        content: e.target.value
                                    }
                                }))}
                                rows={20}
                                cols={100}
                            />
                        </div>
                    </div>
                ))}
            </div>
            <div className="mb-8">
                <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>
            <div className="mb-8">
                <label className="form-label" htmlFor="position">
                    Position
                </label>
                <input
                    className="form-control"
                    type="number"
                    id="position"
                    value={position || ""}
                    onChange={e => setPosition(Number(e.target.value))}
                />
            </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
+139 −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 ServiceEditPageClient from "./ServiceEditPageClient";


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

    const { params } = props;
    const serviceId = (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 (!serviceId || serviceId === "new") {
        // Add mode: pass empty data
        return <>
            <RefreshToken />
            <ServiceEditPageClient data={{ id: 0 }} enabledLanguage={enabledLanguage} />
        </>;
    }

    const res = await fetch(`${process.env.API_URL}/services/${serviceId}`, {
        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 service data</h2>
        </div>;
    }

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

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