user management ui

This commit is contained in:
yaschalew
2026-07-10 10:41:48 +03:00
parent dcb2d98503
commit 28a20923ff
595 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
import { useForm } from "react-hook-form";
import { CreateMessagePayload, MessageFormData } from "../types/messageTypes";
import { toast } from "sonner";
import useSettings from "@/record-management/components/hooks/useSettings";
import { EmployeeWithUnitDto } from "@/user-management/dto/employees/employees";
import { KeyValue } from "@/record-management/types/recordSelectTypes";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMessage } from "../hooks/useMessage";
import MessageForm from "./Form";
export const AddMessage = () => {
const {
register,
handleSubmit,
formState: { errors },
watch,
control,
setValue,
} = useForm<MessageFormData>({
defaultValues: {
messageTitleEn: "",
messageTitleAm: "",
messageContentAm: "",
messageContentEn: "",
employeeId: "",
staffType: "",
isActive: false,
},
});
const { currentEmployeesResponse } = useSettings();
const localizedName = useLocalizedName();
const navigate = useNavigate();
const { createItem } = useMessage();
const [isSubmitting, setIsSubmitting] = useState(false);
const employeesList: KeyValue[] =
currentEmployeesResponse?.items.map((employee: EmployeeWithUnitDto) => ({
label: localizedName(employee.user.name),
value: employee?.id ?? "",
employeePositionId: employee.employeePositions?.[0]?.id ?? "",
})) || [];
const handleCreateSubmit = async (MessageFormData: MessageFormData) => {
setIsSubmitting(true);
try {
const imageFile = MessageFormData.image?.[0];
if (!imageFile) {
throw new Error("images are required");
}
const payload: CreateMessagePayload = {
messageTitle: {
am: MessageFormData.messageTitleAm || "",
en: MessageFormData.messageTitleEn || "",
},
messageContent: {
am: MessageFormData.messageContentAm || "",
en: MessageFormData.messageContentEn || "",
},
fileInfo: {
fileName: imageFile.name,
contentType: imageFile.type,
size: imageFile.size,
originalname: imageFile.name,
},
isActive: MessageFormData.isActive,
staffType: MessageFormData.staffType,
employeeId: MessageFormData.employeeId,
};
await createItem({
data: payload,
imageFile: imageFile,
});
toast.success(" created successfully");
navigate(-1);
} catch (error) {
toast.error("Failed to create Message");
console.error("Creation error:", error);
setIsSubmitting(false);
}
};
const onCancel = () => {
navigate(-1);
};
return (
<MessageForm
handleSubmit={handleSubmit(handleCreateSubmit)}
handleCancel={onCancel}
watch={watch}
setValue={setValue}
control={control}
register={register}
errors={errors}
isSubmitting={isSubmitting}
employeesList={employeesList}
label="Add Message"
/>
);
};

View File

@@ -0,0 +1,144 @@
import {
Control,
FieldErrors,
useForm,
UseFormRegister,
UseFormReset,
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
import { useEffect, useMemo, useState } from "react";
import { EmployeeWithUnitDto } from "@/user-management/dto/employees/employees";
import { KeyValue } from "@/record-management/types/recordSelectTypes";
import useSettings from "@/record-management/components/hooks/useSettings";
import { useLocalizedName } from "@/shared/common/localizedName";
import {
CreateMessagePayload,
MessageFormData,
MessageItem,
} from "../types/messageTypes";
import { useNavigate, useParams } from "react-router-dom";
import { useMessage } from "../hooks/useMessage";
import { toast } from "sonner";
import MessageForm from "./Form";
export interface FormProps {
watch: UseFormWatch<MessageFormData>;
control: Control<MessageFormData>;
setValue: UseFormSetValue<MessageFormData>;
register: UseFormRegister<MessageFormData>;
errors: FieldErrors<MessageFormData>;
reset?: UseFormReset<MessageFormData>;
handleSubmit: (e: React.FormEvent) => void;
handleCancel: () => void;
isSubmitting: boolean;
employeesList: KeyValue[];
item?: MessageItem;
label: string;
}
export const EditMessage = () => {
const { id } = useParams();
const { singleItem: item, updateItem } = useMessage(undefined, id);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const navigate = useNavigate();
const defaultValues = useMemo(
() => ({
messageTitleEn: item?.messageTitle?.en || "",
messageTitleAm: item?.messageTitle?.am || "",
messageContentAm: item?.messageContent?.am || "",
messageContentEn: item?.messageContent?.en || "",
employeeId: item?.employeeId || "",
isActive: item?.isActive ?? true,
staffType: item?.staffType || "",
image: undefined,
}),
[item] // 👈 recompute only when item changes
);
const {
register,
handleSubmit,
watch,
control,
setValue,
reset,
formState: { errors },
} = useForm<MessageFormData>({
defaultValues,
});
useEffect(() => {
if (item) {
reset(defaultValues);
}
}, [item, defaultValues, reset]);
const { currentEmployeesResponse } = useSettings();
const localizedName = useLocalizedName();
const employeesList: KeyValue[] =
currentEmployeesResponse?.items.map((employee: EmployeeWithUnitDto) => ({
label: localizedName(employee.user.name),
value: employee.id || "",
employeePositionId: employee.employeePositions?.[0]?.id ?? "",
})) || [];
const handleEditSubmit = async (MessageFormData: MessageFormData) => {
if (!item) return;
setIsSubmitting(true);
try {
const imageFile = MessageFormData.image?.[0];
const payload: CreateMessagePayload = {
messageTitle: {
am: MessageFormData.messageTitleAm || "",
en: MessageFormData.messageTitleEn || "",
},
messageContent: {
am: MessageFormData.messageContentAm || "",
en: MessageFormData.messageContentEn || "",
},
fileInfo: imageFile
? {
fileName: imageFile.name,
contentType: imageFile.type,
size: imageFile.size,
originalname: imageFile.name,
}
: undefined,
isActive: MessageFormData.isActive,
staffType: MessageFormData.staffType,
employeeId: MessageFormData.employeeId,
};
await updateItem({
id: item?.id,
data: payload,
...(imageFile && { imageFile }),
});
toast.success("message updated successfully");
navigate(-1);
} catch (error) {
toast.error("Failed to update about us");
console.error("Update error:", error);
setIsSubmitting(false);
}
};
const onCancel = () => {
navigate(-1);
};
return (
<MessageForm
handleSubmit={handleSubmit(handleEditSubmit)}
handleCancel={onCancel}
watch={watch}
setValue={setValue}
control={control}
register={register}
errors={errors}
isSubmitting={isSubmitting}
employeesList={employeesList}
reset={reset}
item={item}
label="Edit Message"
/>
);
};

View File

@@ -0,0 +1,159 @@
import { Button } from "@/shared/common/ui/button";
import { Loader2 } from "lucide-react";
import {
FormInputBox,
FormSelector,
FormTextBox,
FormToggle,
ImageUpload,
Section,
} from "../components/FormInputBox";
import { useEffect, useState } from "react";
import { FormProps } from "./EditMessage";
import { ContactUsList } from "../types/contactUs";
const MessageForm = ({
handleSubmit,
handleCancel,
watch,
control,
setValue,
register,
errors,
isSubmitting,
employeesList,
reset,
item,
label,
}: FormProps) => {
const MessageType: ContactUsList[] = [
{
label: "Office Head",
value: "office_head",
},
{
label: "Deputy",
value: "deputy",
},
];
const imageFile = watch("image")?.[0];
const [imagePreview, setImage1Preview] = useState<string>(
item?.presigned || ""
);
useEffect(() => {
if (item?.presigned) {
setImage1Preview(item.presigned);
}
}, [item]);
// 🔹 when user picks a new image, show it
useEffect(() => {
if (imageFile) {
const url = URL.createObjectURL(imageFile);
setImage1Preview(url);
return () => URL.revokeObjectURL(url);
}
}, [imageFile]);
useEffect(() => {
if (employeesList.length && !watch("employeeId")) {
setValue("employeeId", employeesList[0].value);
}
}, [employeesList, setValue, watch]);
return (
<div className="max-h-[80vh] overflow-y-auto p-6 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<form onSubmit={handleSubmit} className="space-y-8">
{/* message type */}
<FormSelector
id="staffType"
label="Message Type"
error="Type is Required"
errors={errors}
register={register}
control={control}
item={MessageType}
/>
{/* Employee Selection */}
<FormSelector
id="employeeId"
label="Select Employee"
error="Employee is Required"
errors={errors}
register={register}
control={control}
item={employeesList}
/>
{/* Title Section */}
<FormInputBox
idEn="messageTitleEn"
idAm="messageTitleAm"
label="Title"
error="Title is Required"
errors={errors}
register={register}
/>
{/* Content Section */}
<FormTextBox
idAm="messageContentAm"
idEn="messageContentEn"
label="Content"
error="Content is Required"
errors={errors}
register={register}
/>
{/* isActive Toggle */}
<FormToggle
idAm="isActive"
label="Status"
watch={watch}
setValue={setValue}
/>
{/* Image Upload */}
<Section title="Image">
<ImageUpload
label="Experience Image*"
preview={imagePreview}
existingName={item?.fileInfo?.originalname}
register={register("image")}
error={errors.image?.message}
onView={() => window.open(imagePreview, "_blank")}
id="image"
isRequired={false}
/>
</Section>
{/* Actions */}
<div className="flex justify-end gap-3 pt-6 border-t border-gray-200 dark:border-gray-700">
<Button
variant="outline"
onClick={handleCancel}
type="button"
disabled={isSubmitting}
className="px-6 py-2 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white"
>
{isSubmitting ? (
<span className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
...
</span>
) : (
label
)}
</Button>
</div>
</form>
</div>
);
};
export default MessageForm;

View File

@@ -0,0 +1,105 @@
import { ColumnDef } from "@tanstack/react-table";
import { Button } from "@/shared/common/ui/button";
import { Trash2, Edit, Eye } from "lucide-react";
import { MessageItem } from "../types/messageTypes";
import { t } from "i18next";
import i18n from "@/i18n";
export const MessageColumnDefn = ({
navigate,
onDelete,
}: {
navigate: (id: string) => void;
onDelete: (id: string) => Promise<void>;
}): ColumnDef<MessageItem>[] => [
{
header: () => t("toogle.title"),
cell: ({ row }) => {
const rowData = row.original;
const lang = i18n.language;
return (
<span>
{lang === "en" ? rowData.messageTitle.en : rowData.messageTitle.am}
</span>
);
},
enableHiding: true,
accessorKey: "title",
},
{
accessorKey: "status",
header: () => t("toogle.status"),
cell: ({ row }) => {
const rowData = row.original;
return (
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
rowData.isActive
? "bg-primary-100 text-primary-800"
: "bg-red-100 text-red-800"
}`}
>
{rowData.isActive ? "Active" : "Inactive"}
</span>
);
},
enableHiding: true,
},
{
accessorKey: "createdAt",
header: () => t("toogle.createdAt"),
cell: ({ row }) => {
const rowData = row.original;
const lang = i18n.language;
const amDate = rowData?.amharicCreatedAt;
const enDate = new Date(rowData.createdAt).toLocaleString("en-US", {
month: "short", // "Oct"
day: "numeric", // "12"
year: "numeric", // "2014"
hour: "numeric", // "8"
minute: "2-digit", // "12"
hour12: true, // "PM" format
});
const date = lang === "am" ? amDate : enDate;
return <span className="text-sm text-gray-500">{date}</span>;
},
enableHiding: true,
},
{
accessorKey: "Actions",
header: () => t("toogle.Actions"),
cell: ({ row }) => (
<div className="flex space-x-2">
<Button
variant="ghost"
size="icon"
onClick={() =>
navigate(
`/user-management/web-management/LeadershipMessage/ViewLeadershipMessage/${row.original.id}`
)
}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() =>
navigate(
`/user-management/web-management/LeadershipMessage/EditLeadershipMessage/${row.original.id}`
)
}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(row.original.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
),
},
];

View File

@@ -0,0 +1,87 @@
import { NavLink, useNavigate } from "react-router-dom";
import { CirclePlus } from "lucide-react";
import { MessageColumnDefn } from "./MessageColumn";
import { useTranslation } from "react-i18next";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { useMemo, useState } from "react";
import Loader from "@/record-management/components/Loader/loader";
import { Button } from "@/shared/common/ui/button";
import { useMessage } from "../hooks/useMessage";
export const Messages = () => {
const [pageIndex, setPageIndex] = useState(0);
const navigate = useNavigate();
const pageSize = 10;
const { t } = useTranslation();
const baseParams = useMemo(() => {
const params: Record<string, any> = {
skip: pageIndex * pageSize,
take: pageSize,
orderBy: "createdAt:DESC",
};
return params;
}, [pageIndex]);
const { items, refetch, deleteItem, error, isLoading, count } =
useMessage(baseParams);
const handlePageChange = (newPage: number) => {
setPageIndex(newPage);
};
return (
<div className="flex flex-col h-full w-full p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
{/* Header Section */}
<div className="flex items-center justify-between mb-4 border-b pb-3 border-gray-200 dark:border-gray-700">
<h1 className="text-xl font-semibold text-gray-800 dark:text-gray-200">
{t("officeHead.title")}
</h1>
{
<NavLink to="/user-management/web-Management/LeadershipMessage/AddLeadershipMessage">
<Button
variant="addRecordBtn"
className="flex items-center gap-1 bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg transition-all"
>
<CirclePlus className="w-4 h-4" />
<span>{t("officeHead.addMessage")}</span>
</Button>
</NavLink>
}
</div>
{/* Content Section */}
<div className="flex-1 overflow-auto">
{isLoading && (
<div className="flex justify-center items-center h-48">
<Loader />
</div>
)}
{error && (
<p className="text-center text-red-500 text-sm mt-4">
{t("userRecord.Error loading records.")}
</p>
)}
{!isLoading && !error && (
<div className="mt-2">
<AdvancedTable
columns={MessageColumnDefn({
navigate,
onDelete: deleteItem,
})}
data={items || []}
tableName="LeadershipMessages"
toolBarPosition="right"
extraToolbar={<div className="flex gap-2"></div>}
itemCount={count || 2}
pageIndex={pageIndex}
onPageChange={handlePageChange}
nextFunction={() => handlePageChange(pageIndex + 1)}
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
refresh={refetch}
/>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,92 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useLocalizedName } from "@/shared/common/localizedName";
import { MessageItem } from "../types/messageTypes";
interface ViewMessageProps {
item: MessageItem;
}
export const ViewMessage = ({ item }: ViewMessageProps) => {
const { t } = useTranslation();
const localizedName = useLocalizedName();
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 flex items-center justify-center p-4 md:p-8">
<div className="max-w-6xl w-full">
{/* Header */}
<div className="text-center mb-12">
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
{localizedName(item.messageTitle) || t("Leadership Message")}
</h1>
<div className="w-24 h-1 bg-primary-600 mx-auto"></div>
<p className="text-gray-600 mt-4 max-w-2xl mx-auto">
{t("Message from our leadership")}
</p>
</div>
{/* Message Card */}
<div className="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl">
<div className="flex flex-col md:flex-row">
{/* Image Section with overlay */}
<div className="md:w-2/5 relative">
<img
src={item.presigned || "/placeholder.jpg"}
alt={localizedName(item.messageTitle) || "Leadership"}
className="w-full h-64 md:h-full object-cover transition-opacity duration-500"
/>
{/* Overlay with name & position */}
{/* <div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent flex items-end p-6 md:p-8">
<div>
<h2 className="text-2xl md:text-3xl font-bold text-white mb-2">
{localizedName(item.employeeName) || t("Office Head")}
</h2>
<p className="text-gray-200 text-lg">
{localizedName(item.employeePosition) || ""}
</p>
</div>
</div> */}
</div>
{/* Message Content Section */}
<div className="md:w-3/5 p-6 md:p-8 flex flex-col justify-center">
<div className="mb-6">
<div className="flex items-center mb-6">
<div className="w-10 h-1 bg-primary-600 mr-3"></div>
<h3 className="text-lg font-semibold text-primary-600 uppercase tracking-wide">
{localizedName(item.messageTitle) ||
t("Leadership Message")}
</h3>
</div>
{/* Localized Message Content */}
<div className="min-h-[200px] flex items-center">
<p className="text-gray-700 text-lg leading-relaxed">
{localizedName(item.messageContent) ||
t("No content available")}
</p>
</div>
</div>
{/* Status Section */}
<div className="mt-8 flex justify-end items-center gap-3 border-t pt-4">
<span className="text-sm font-medium text-gray-700">
{t("Status")}:
</span>
<span
className={`px-4 py-2 text-sm font-semibold rounded-full ${
item.isActive
? "bg-primary-100 text-primary-700"
: "bg-gray-200 text-gray-600"
}`}>
{item.isActive ? t("Active") : t("Inactive")}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,137 @@
import React from "react";
import { ImageIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { MessageItem } from "../types/messageTypes";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useMessage } from "../hooks/useMessage";
import { useParams } from "react-router-dom";
export const ViewMessage = () => {
const { i18n, t } = useTranslation();
const { id } = useParams<{ id: string }>();
const { singleItem: item } = useMessage(undefined, id);
const locale = i18n.language;
const localizedName = useLocalizedName();
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4 md:p-8">
<div className="max-w-6xl w-full">
{/* Header */}
<div className="text-center mb-12">
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
{t("Leadership Message")}
</h1>
<div className="w-24 h-1 bg-indigo-600 mx-auto"></div>
</div>
{/* Card */}
<div className="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl">
<div className="flex flex-col md:flex-row">
{/* Image Section */}
<div className="md:w-2/5 relative">
{item?.presigned ? (
<img
src={item.presigned}
alt={localizedName(item.messageTitle)}
className="w-full h-64 md:h-full object-cover"
/>
) : (
<div className="w-full h-64 md:h-full flex items-center justify-center bg-gray-100 text-gray-400">
<ImageIcon className="h-10 w-10" />
<span className="ml-2">{t("No image available")}</span>
</div>
)}
{/* Overlay with title */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent flex items-end p-6 md:p-8">
<div>
<h2 className="text-2xl md:text-3xl font-bold text-white mb-2">
{localizedName(item?.messageTitle)}
</h2>
<p className="text-gray-200 text-lg">
{t("Office Leadership")}
</p>
</div>
</div>
{/* Checkmark */}
<div className="absolute top-6 right-6 bg-white text-indigo-600 rounded-full p-2 shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
</div>
{/* Message Section */}
<div className="md:w-3/5 p-6 md:p-8 flex flex-col justify-center">
<div className="mb-6">
<div className="flex items-center mb-6">
<div className="w-10 h-1 bg-indigo-600 mr-3"></div>
<h3 className="text-lg font-semibold text-indigo-600 uppercase tracking-wide">
{t("Message Content")}
</h3>
</div>
<div className="max-h-96 overflow-y-auto pr-4 custom-scrollbar">
<p className="text-gray-700 text-lg leading-relaxed whitespace-pre-line">
{localizedName(item?.messageContent)}
</p>
</div>
</div>
{/* Footer Section */}
<div className="mt-8 pt-6 border-t border-gray-200 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
{/* Status Toggle */}
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-700">
{t("Status")}
</span>
<div
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
item?.isActive ? "bg-primary-500" : "bg-gray-300"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
item?.isActive ? "translate-x-6" : "translate-x-1"
}`}
/>
</div>
<span
className={`text-sm font-medium ${
item?.isActive ? "text-primary-600" : "text-gray-500"
}`}
>
{item?.isActive ? t("Active") : t("Inactive")}
</span>
</div>
{/* Date */}
{item?.createdAt && (
<span className="text-sm text-gray-500">
{locale === "en"
? new Date(item.createdAt).toLocaleString(locale, {
dateStyle: "medium",
timeStyle: "short",
})
: item.amharicCreatedAt}
</span>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};