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,213 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/common/ui/dialog";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { SiteDto, SitePayloadDto } from "@/super-admin/dto/SitesDto";
import { toSnakeCase } from "@/super-admin/hooks/useSites";
interface SiteFormModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (payload: SitePayloadDto) => void;
site?: SiteDto | null; // If provided, we are in Edit mode
isSubmitting: boolean;
existingKeys: string[]; // for frontend uniqueness check
}
export default function SiteFormModal({
isOpen,
onClose,
onSubmit,
site = null,
isSubmitting,
existingKeys,
}: SiteFormModalProps) {
const { t } = useTranslation();
const isEdit = !!site;
const [nameAm, setNameAm] = useState("");
const [nameEn, setNameEn] = useState("");
const [key, setKey] = useState("");
const [domain, setDomain] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (isOpen) {
if (site) {
setNameAm(site.name?.am || "");
setNameEn(site.name?.en || "");
setKey(site.key || "");
setDomain(site.domain || "");
} else {
setNameAm("");
setNameEn("");
setKey("");
setDomain("");
}
setErrors({});
}
}, [isOpen, site]);
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!nameAm.trim()) {
newErrors.nameAm = t("sites.form.nameAmRequired");
}
if (!nameEn.trim()) {
newErrors.nameEn = t("sites.form.nameEnRequired");
}
if (!key.trim()) {
newErrors.key = t("sites.form.keyRequired");
} else if (!/^[a-zA-Z0-9_-]+$/.test(key)) {
newErrors.key = t("sites.form.keyFormat");
} else if (!isEdit && existingKeys.includes(key.trim())) {
newErrors.key = t("sites.form.keyUnique");
}
if (!domain.trim()) {
newErrors.domain = t("sites.form.domainRequired");
} else if (/\s/.test(domain)) {
newErrors.domain = t("sites.form.domainNoSpaces");
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
onSubmit({
name: {
am: nameAm.trim(),
en: nameEn.trim(), // transformed to snake_case in the hook/mutation
},
key: key.trim(),
domain: domain.trim(),
});
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold text-slate-800 dark:text-slate-100">
{isEdit ? t("sites.form.titleEdit") : t("sites.form.titleAdd")}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-5 py-2">
{/* Site Name (Amharic) */}
<div className="space-y-1">
<Label htmlFor="nameAm" className="text-sm font-semibold">
{t("sites.form.nameAmLabel")} <span className="text-red-500">*</span>
</Label>
<Input
id="nameAm"
value={nameAm}
onChange={(e) => setNameAm(e.target.value)}
placeholder={t("sites.form.nameAmPlaceholder")}
className={errors.nameAm ? "border-red-500 focus-visible:ring-red-500" : ""}
/>
{errors.nameAm && (
<span className="text-xs text-red-500 font-medium">{errors.nameAm}</span>
)}
</div>
{/* Site Name (English) */}
<div className="space-y-1">
<Label htmlFor="nameEn" className="text-sm font-semibold">
{t("sites.form.nameEnLabel")} <span className="text-red-500">*</span>
</Label>
<Input
id="nameEn"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
placeholder={t("sites.form.nameEnPlaceholder")}
className={errors.nameEn ? "border-red-500 focus-visible:ring-red-500" : ""}
/>
{nameEn.trim() && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{t("sites.form.previewLabel")} <span className="font-mono text-primary font-semibold">{toSnakeCase(nameEn)}</span>
</p>
)}
{errors.nameEn && (
<span className="text-xs text-red-500 font-medium">{errors.nameEn}</span>
)}
</div>
{/* Site Key */}
<div className="space-y-1">
<Label htmlFor="key" className="text-sm font-semibold">
{t("sites.form.keyLabel")} <span className="text-red-500">*</span>
</Label>
<Input
id="key"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder={t("sites.form.keyPlaceholder")}
disabled={isEdit}
className={errors.key ? "border-red-500 focus-visible:ring-red-500" : ""}
/>
{!isEdit && (
<p className="text-xs text-slate-400">
{t("sites.form.keyHint")}
</p>
)}
{errors.key && (
<span className="text-xs text-red-500 font-medium">{errors.key}</span>
)}
</div>
{/* Site Domain */}
<div className="space-y-1">
<Label htmlFor="domain" className="text-sm font-semibold">
{t("sites.form.domainLabel")} <span className="text-red-500">*</span>
</Label>
<Input
id="domain"
value={domain}
onChange={(e) => setDomain(e.target.value)}
placeholder={t("sites.form.domainPlaceholder")}
className={errors.domain ? "border-red-500 focus-visible:ring-red-500" : ""}
/>
{errors.domain && (
<span className="text-xs text-red-500 font-medium">{errors.domain}</span>
)}
</div>
<DialogFooter className="pt-4 border-t gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isSubmitting}
className="w-full sm:w-auto"
>
{t("common.cancel")}
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="w-full sm:w-auto"
>
{isSubmitting ? t("sites.form.saving") : isEdit ? t("sites.form.saveChanges") : t("sites.form.createSite")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,104 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/common/ui/dialog";
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import { useTranslation } from "react-i18next";
import { SiteDto } from "@/super-admin/dto/SitesDto";
interface SiteViewModalProps {
isOpen: boolean;
onClose: () => void;
site?: SiteDto | null;
}
export default function SiteViewModal({
isOpen,
onClose,
site,
}: SiteViewModalProps) {
const { t } = useTranslation();
if (!site) return null;
const isActive = site.status === "Active" || site.status !== "Archived";
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold text-slate-800 dark:text-slate-100">
{t("sites.view.title")}
</DialogTitle>
</DialogHeader>
<div className="py-2 space-y-4">
{/* Detail cards */}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 p-3 bg-slate-50 dark:bg-slate-900 rounded-lg space-y-1">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
{t("sites.view.nameEn")}
</span>
<p className="text-base font-semibold text-slate-800 dark:text-slate-100">
{site.name?.en || "-"}
</p>
</div>
<div className="col-span-2 p-3 bg-slate-50 dark:bg-slate-900 rounded-lg space-y-1">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
{t("sites.view.nameAm")}
</span>
<p className="text-base font-semibold text-slate-800 dark:text-slate-100">
{site.name?.am || "-"}
</p>
</div>
<div className="p-3 bg-slate-50 dark:bg-slate-900 rounded-lg space-y-1">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
{t("sites.view.siteKey")}
</span>
<p className="text-base font-mono text-slate-800 dark:text-slate-100 truncate" title={site.key}>
{site.key || "-"}
</p>
</div>
<div className="p-3 bg-slate-50 dark:bg-slate-900 rounded-lg space-y-1">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
{t("sites.view.status")}
</span>
<div>
<Badge
className={`${
isActive
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-950/40"
: "bg-amber-100 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-950/40"
} rounded-full px-3 py-0.5 font-medium border-none`}
>
{site.status || "Active"}
</Badge>
</div>
</div>
<div className="col-span-2 p-3 bg-slate-50 dark:bg-slate-900 rounded-lg space-y-1">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
{t("sites.view.domain")}
</span>
<p className="text-base font-semibold text-slate-800 dark:text-slate-100 truncate" title={site.domain}>
{site.domain || "-"}
</p>
</div>
</div>
</div>
<DialogFooter className="border-t pt-4">
<Button onClick={onClose} className="w-full sm:w-auto">
{t("sites.view.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,143 @@
import { SiteDto } from "@/super-admin/dto/SitesDto";
import { ColumnDef } from "@tanstack/react-table";
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import { Archive, Trash2, Edit, Eye, MoreHorizontal, RefreshCw, Palette } from "lucide-react";
import { t } from "i18next";
interface ColumnsProps {
onView: (site: SiteDto) => void;
onEdit: (site: SiteDto) => void;
onArchive: (site: SiteDto) => void;
onRestore: (site: SiteDto) => void;
onDelete: (site: SiteDto) => void;
onBranding: (site: SiteDto) => void;
}
export const getSitesColumnDefn = ({
onView,
onEdit,
onArchive,
onRestore,
onDelete,
onBranding,
}: ColumnsProps): ColumnDef<SiteDto>[] => [
{
id: "name",
header: () => t("sites.columns.siteName"),
cell: ({ row }) => {
const site = row.original;
return (
<div>
<div className="font-semibold text-slate-800 dark:text-slate-200">
{site.name?.en || "-"}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{site.name?.am || "-"}
</div>
</div>
);
},
},
{
accessorKey: "key",
header: () => t("sites.columns.key"),
cell: ({ row }) => (
<span className="font-mono text-slate-700 dark:text-slate-300">
{row.original.key}
</span>
),
},
{
accessorKey: "domain",
header: () => t("sites.columns.domain"),
cell: ({ row }) => (
<span className="text-slate-700 dark:text-slate-300">
{row.original.domain}
</span>
),
},
{
accessorKey: "status",
header: () => t("sites.columns.status"),
cell: ({ row }) => {
const status = row.original.status || "Active";
const isActive = status === "Active";
return (
<Badge
className={`${
isActive
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-950/40"
: "bg-amber-100 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-950/40"
} rounded-full px-3 py-0.5 font-medium border-none`}
>
{status}
</Badge>
);
},
},
{
id: "actions",
header: () => t("sites.columns.actions"),
cell: ({ row }) => {
const site = row.original;
const isArchived = site.status === "Archived";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open Menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
<DropdownMenuLabel>{t("sites.columns.actions")}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => onView(site)}>
<Eye className="mr-2 h-4 w-4 text-slate-500" />
{t("sites.actions.view")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(site)}>
<Edit className="mr-2 h-4 w-4 text-blue-500" />
{t("sites.actions.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onBranding(site)}>
<Palette className="mr-2 h-4 w-4 text-purple-500" />
{t("sites.actions.branding")}
</DropdownMenuItem>
{!isArchived ? (
<DropdownMenuItem
onClick={() => onArchive(site)}
className="text-amber-600 focus:text-amber-600 cursor-pointer"
>
<Archive className="mr-2 h-4 w-4 text-amber-500" />
{t("sites.actions.archive")}
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => onRestore(site)}
className="text-emerald-600 focus:text-emerald-600 cursor-pointer"
>
<RefreshCw className="mr-2 h-4 w-4 text-emerald-500" />
{t("sites.actions.restore")}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => onDelete(site)}
className="text-rose-600 focus:text-rose-600 cursor-pointer"
>
<Trash2 className="mr-2 h-4 w-4 text-rose-500" />
{t("sites.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];

View File

@@ -0,0 +1,363 @@
import { useState, useMemo, useEffect } from "react";
import { useSites } from "@/super-admin/hooks/useSites";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Button } from "@/shared/common/ui/button";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { getSitesColumnDefn } from "./SitesColumnDefn";
import { Loader, Plus } from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { SiteDto, SitePayloadDto } from "@/super-admin/dto/SitesDto";
import SiteFormModal from "./SiteFormModal";
import SiteViewModal from "./SiteViewModal";
export default function SitesPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [searchTerm, setSearchTerm] = useState("");
// Modals state
const [formOpen, setFormOpen] = useState(false);
const [viewOpen, setViewOpen] = useState(false);
const [selectedSite, setSelectedSite] = useState<SiteDto | null>(null);
// Dialogs state for archive/delete/restore
const [archiveDialogOpen, setArchiveDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [restoreDialogOpen, setRestoreDialogOpen] = useState(false);
// Query & mutation hook
const {
sitesResponse,
isLoading,
refetch,
createSite,
isCreating,
updateSite,
isUpdating,
archiveSite,
isArchiving,
deleteSite,
isDeleting,
restoreSite,
isRestoring,
} = useSites({
take: pageSize,
skip: pageIndex * pageSize,
orderBy: "createdAt:DESC",
});
useEffect(() => {
setPageIndex(0);
}, [searchTerm, pageSize]);
const handlePageChange = (newPage: number) => {
setPageIndex(newPage);
};
const handleAddNewClick = () => {
setSelectedSite(null);
setFormOpen(true);
};
const handleFormSubmit = (payload: SitePayloadDto) => {
if (selectedSite) {
updateSite(
{ id: selectedSite.id, payload },
{
onSuccess: () => {
setFormOpen(false);
setSelectedSite(null);
},
},
);
} else {
createSite(payload, {
onSuccess: () => {
setFormOpen(false);
},
});
}
};
const handleArchiveConfirm = () => {
if (selectedSite) {
archiveSite(
{ id: selectedSite.id },
{
onSuccess: () => {
setArchiveDialogOpen(false);
setSelectedSite(null);
},
},
);
}
};
const handleDeleteConfirm = () => {
if (selectedSite) {
deleteSite(
{ id: selectedSite.id },
{
onSuccess: () => {
setDeleteDialogOpen(false);
setSelectedSite(null);
},
},
);
}
};
const handleRestoreConfirm = () => {
if (selectedSite) {
restoreSite(
{ id: selectedSite.id },
{
onSuccess: () => {
setRestoreDialogOpen(false);
setSelectedSite(null);
},
},
);
}
};
// Extract all sites from response
const allSites = useMemo(() => sitesResponse?.items || [], [sitesResponse]);
// Existing keys list (for create uniqueness validation)
const existingKeys = useMemo(() => allSites.map((s) => s.key), [allSites]);
// Get table column definitions
const columns = useMemo(
() =>
getSitesColumnDefn({
onView: (site) => {
setSelectedSite(site);
setViewOpen(true);
},
onEdit: (site) => {
setSelectedSite(site);
setFormOpen(true);
},
onArchive: (site) => {
setSelectedSite(site);
setArchiveDialogOpen(true);
},
onRestore: (site) => {
setSelectedSite(site);
setRestoreDialogOpen(true);
},
onDelete: (site) => {
setSelectedSite(site);
setDeleteDialogOpen(true);
},
onBranding: (site) => {
navigate(`/user-management/web-management/Branding/Branding/${site.id}`);
},
}),
[],
);
return (
<div className="p-6 space-y-6">
<Card className="col-span-2 shadow-none border-none bg-transparent px-0">
<CardHeader className="flex flex-row justify-between items-center px-0">
<CardTitle className="text-2xl font-bold text-slate-800 dark:text-slate-100">
{t("sites.title")}
</CardTitle>
</CardHeader>
<CardContent className="px-0">
<AdvancedTable
columns={columns}
data={sitesResponse?.items || []}
tableName={t("sites.tableName")}
toolBarPosition="right"
itemCount={sitesResponse?.count || 0}
pageSize={pageSize}
onGlobalFilterChange={setSearchTerm}
extraToolbar={
<Button
onClick={handleAddNewClick}
className="px-5 py-2 rounded-md text-sm font-medium shadow-md cursor-pointer"
>
<Plus className="w-4 h-4 mr-2" />
{t("sites.addNew")}
</Button>
}
pageIndex={pageIndex}
onPageChange={handlePageChange}
onPageSizeChange={setPageSize}
nextFunction={() => handlePageChange(pageIndex + 1)}
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
refresh={refetch}
isLoading={isLoading}
/>
</CardContent>
</Card>
{/* Create / Edit Form Modal */}
<SiteFormModal
isOpen={formOpen}
onClose={() => {
setFormOpen(false);
setSelectedSite(null);
}}
onSubmit={handleFormSubmit}
site={selectedSite}
isSubmitting={isCreating || isUpdating}
existingKeys={existingKeys}
/>
{/* Read-Only View Modal */}
<SiteViewModal
isOpen={viewOpen}
onClose={() => {
setViewOpen(false);
setSelectedSite(null);
}}
site={selectedSite}
/>
{/* Archive Confirmation (Soft Delete) */}
<AlertDialog
open={archiveDialogOpen}
onOpenChange={(open) => !open && setArchiveDialogOpen(false)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-lg font-bold">
{t("sites.dialogs.archiveTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("sites.dialogs.archiveDescription", {
name: selectedSite?.name?.en,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
disabled={isArchiving}
onClick={() => {
setArchiveDialogOpen(false);
setSelectedSite(null);
}}
className="cursor-pointer"
>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
disabled={isArchiving}
onClick={handleArchiveConfirm}
className="bg-amber-600 hover:bg-amber-700 text-white cursor-pointer"
>
{isArchiving
? t("sites.dialogs.archiving")
: t("sites.dialogs.archiveConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Permanent Delete Confirmation (Hard Delete) */}
<AlertDialog
open={deleteDialogOpen}
onOpenChange={(open) => !open && setDeleteDialogOpen(false)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-lg font-bold text-red-600">
{t("sites.dialogs.deleteTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
<span
dangerouslySetInnerHTML={{
__html: t("sites.dialogs.deleteDescription", {
name: selectedSite?.name?.en,
}),
}}
/>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
disabled={isDeleting}
onClick={() => {
setDeleteDialogOpen(false);
setSelectedSite(null);
}}
className="cursor-pointer"
>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
disabled={isDeleting}
onClick={handleDeleteConfirm}
className="bg-red-600 hover:bg-red-700 text-white cursor-pointer"
>
{isDeleting
? t("sites.dialogs.deleting")
: t("sites.dialogs.deleteConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Restore Confirmation */}
<AlertDialog
open={restoreDialogOpen}
onOpenChange={(open) => !open && setRestoreDialogOpen(false)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-lg font-bold">
{t("sites.dialogs.restoreTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("sites.dialogs.restoreDescription", {
name: selectedSite?.name?.en,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
disabled={isRestoring}
onClick={() => {
setRestoreDialogOpen(false);
setSelectedSite(null);
}}
className="cursor-pointer"
>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
disabled={isRestoring}
onClick={handleRestoreConfirm}
className="bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
>
{isRestoring
? t("sites.dialogs.restoring")
: t("sites.dialogs.restoreConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}