Merge pull request 'azeem' (#1) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/1
This commit is contained in:
@@ -1,192 +1,567 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import { CustomLoader, CustomModal, CustomInput, CustomButton } from "../../components/custom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { authApi } from "../authentication/AuthApi";
|
||||
import { Key, Pencil } from "lucide-react";
|
||||
import type { PasswordForm, FormatDateFunction, FormatNameFunction, RenderStatusBadgeFunction } from "./ProfileTypes";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { authApi } from "../Authentication/AuthApi";
|
||||
import { paletteApi } from "../theme/PaletteApi";
|
||||
import type { ColorPalette } from "../theme/ThemeTypes";
|
||||
import CustomButton from "../../components/custom/CustomButton";
|
||||
import { Loader } from "../../components/custom/CustomLoader";
|
||||
import { Check, User } from "lucide-react";
|
||||
|
||||
type ProfileForm = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phoneNumber: string;
|
||||
};
|
||||
|
||||
const formatDate: FormatDateFunction = (value: string) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatName: FormatNameFunction = (firstName: string, lastName?: string | null) =>
|
||||
[firstName, lastName].filter(Boolean).join(" ");
|
||||
|
||||
const renderStatusBadge: RenderStatusBadgeFunction = (status?: string) => (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold ${status === "active"
|
||||
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{status ? status.charAt(0).toUpperCase() + status.slice(1) : "Unknown"}
|
||||
</span>
|
||||
);
|
||||
|
||||
const ProfilePage: React.FC = () => {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const { t } = useTranslation(['profile', 'common']);
|
||||
const { user, isLoading, refreshUser } = useAuth();
|
||||
|
||||
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
|
||||
const [passwordForm, setPasswordForm] = useState<PasswordForm>({
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
const [passwordError, setPasswordError] = useState("");
|
||||
const [passwordSuccess, setPasswordSuccess] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [profileForm, setProfileForm] = useState<ProfileForm>({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
phoneNumber: "",
|
||||
});
|
||||
const [profileError, setProfileError] = useState("");
|
||||
const [profileSuccess, setProfileSuccess] = useState("");
|
||||
const [isProfileSubmitting, setIsProfileSubmitting] = useState(false);
|
||||
|
||||
// Theme Logic
|
||||
const { currentPalette, setTheme } = useTheme();
|
||||
|
||||
const [palettes, setPalettes] = useState<ColorPalette[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Form states
|
||||
const [firstName, setFirstName] = useState(user?.first_name || "");
|
||||
const [lastName, setLastName] = useState(user?.last_name || "");
|
||||
const [phone, setPhone] = useState(user?.phone_number || "");
|
||||
const [isThemesLoading, setIsThemesLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPalettes = async () => {
|
||||
setIsLoading(true);
|
||||
setIsThemesLoading(true);
|
||||
try {
|
||||
const data = await paletteApi.getAllPalettes();
|
||||
setPalettes(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch palettes", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsThemesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPalettes();
|
||||
}, []);
|
||||
|
||||
// Update form when user data changes
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setFirstName(user.first_name || "");
|
||||
setLastName(user.last_name || "");
|
||||
setPhone(user.phone_number || "");
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleThemeSelect = (palette: ColorPalette) => {
|
||||
setTheme(palette);
|
||||
toast.success("Theme updated successfully");
|
||||
};
|
||||
|
||||
const handleProfileUpdate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await authApi.updateProfile(user.id, {
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
phone_number: phone,
|
||||
});
|
||||
await refreshUser();
|
||||
toast.success("Profile updated successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to update profile", error);
|
||||
toast.error("Failed to update profile");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading && palettes.length === 0) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Loader />
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<CustomLoader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-6 text-sm text-yellow-800">
|
||||
{t('messages.loadError')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fullName = formatName(user.first_name, user.last_name);
|
||||
const initials =
|
||||
user.first_name && user.last_name
|
||||
? `${user.first_name[0]}${user.last_name[0]}`
|
||||
: user.first_name
|
||||
? user.first_name[0]
|
||||
: "U";
|
||||
|
||||
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setPasswordForm((prev) => ({ ...prev, [name]: value }));
|
||||
setPasswordError("");
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPasswordError("");
|
||||
setPasswordSuccess("");
|
||||
|
||||
// Client-side validation
|
||||
if (!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) {
|
||||
setPasswordError(t('messages.requiredFields'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordForm.newPassword.length < 8) {
|
||||
setPasswordError(t('messages.passwordLength'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
setPasswordError(t('messages.passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await authApi.resetPassword(
|
||||
passwordForm.currentPassword,
|
||||
passwordForm.newPassword
|
||||
);
|
||||
setPasswordSuccess(response.message || t('messages.passwordSuccess'));
|
||||
setPasswordForm({
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
|
||||
// Close modal after 1.5 seconds
|
||||
setTimeout(() => {
|
||||
setIsPasswordModalOpen(false);
|
||||
setPasswordSuccess("");
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to update password";
|
||||
setPasswordError(errorMessage);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordModalClose = () => {
|
||||
setIsPasswordModalOpen(false);
|
||||
setPasswordForm({
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
setPasswordError("");
|
||||
setPasswordSuccess("");
|
||||
};
|
||||
|
||||
// Edit Profile handlers
|
||||
const handleOpenEditModal = () => {
|
||||
if (user) {
|
||||
setProfileForm({
|
||||
firstName: user.first_name || "",
|
||||
lastName: user.last_name || "",
|
||||
phoneNumber: user.phone_number || "",
|
||||
});
|
||||
}
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleProfileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setProfileForm((prev) => ({ ...prev, [name]: value }));
|
||||
setProfileError("");
|
||||
};
|
||||
|
||||
const handleProfileSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setProfileError("");
|
||||
setProfileSuccess("");
|
||||
|
||||
if (!user) return;
|
||||
|
||||
if (!profileForm.firstName.trim()) {
|
||||
setProfileError(t('messages.firstNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProfileSubmitting(true);
|
||||
|
||||
try {
|
||||
await authApi.updateProfile(user.id, {
|
||||
first_name: profileForm.firstName.trim(),
|
||||
last_name: profileForm.lastName.trim() || undefined,
|
||||
phone_number: profileForm.phoneNumber.trim() || undefined,
|
||||
});
|
||||
setProfileSuccess(t('messages.profileSuccess'));
|
||||
|
||||
// Refresh user data
|
||||
if (refreshUser) {
|
||||
await refreshUser();
|
||||
}
|
||||
|
||||
// Close modal after 1.5 seconds
|
||||
setTimeout(() => {
|
||||
setIsEditModalOpen(false);
|
||||
setProfileSuccess("");
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to update profile";
|
||||
setProfileError(errorMessage);
|
||||
} finally {
|
||||
setIsProfileSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditModalClose = () => {
|
||||
setIsEditModalOpen(false);
|
||||
setProfileError("");
|
||||
setProfileSuccess("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8 animate-fade-in">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-[var(--text-primary)]">Profile Settings</h1>
|
||||
<p className="text-[var(--text-secondary)] mt-2">Manage your account settings and preferences.</p>
|
||||
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">{t('title')}</h1>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('subTitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Information Section */}
|
||||
<div className="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-xl p-6 shadow-sm">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="p-3 bg-blue-100 rounded-lg text-blue-600">
|
||||
<User size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">Personal Information</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Update your personal details.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleProfileUpdate} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--text-primary)]">First Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-[var(--card-border)] bg-[var(--background)] text-[var(--text-primary)] focus:ring-2 focus:ring-[var(--primary)] outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--text-primary)]">Last Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-[var(--card-border)] bg-[var(--background)] text-[var(--text-primary)] focus:ring-2 focus:ring-[var(--primary)] outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--text-primary)]">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={user?.email || ""}
|
||||
disabled
|
||||
className="w-full px-3 py-2 rounded-lg border border-[var(--card-border)] bg-[var(--background-secondary)] text-[var(--text-secondary)] cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--text-primary)]">Phone Number</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-[var(--card-border)] bg-[var(--background)] text-[var(--text-primary)] focus:ring-2 focus:ring-[var(--primary)] outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<CustomButton type="submit" loading={isSaving} variant="primary">
|
||||
Save Changes
|
||||
</CustomButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Theme Selection Section */}
|
||||
<div className="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-xl p-6 shadow-sm">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">Theme Preferences</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Choose a color theme for your interface.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{palettes.map((palette) => (
|
||||
<button
|
||||
key={palette.id}
|
||||
onClick={() => handleThemeSelect(palette)}
|
||||
className={`
|
||||
relative flex items-center gap-3 p-3 rounded-xl border text-left transition-all duration-200
|
||||
${currentPalette?.id === palette.id
|
||||
? "border-[var(--primary)] bg-[var(--primary)]/5 ring-1 ring-[var(--primary)]"
|
||||
: "border-[var(--card-border)] hover:border-[var(--text-secondary)]"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-6 h-6 rounded-full shadow-sm border border-[var(--card-border)]" style={{ backgroundColor: palette.colors.primary }} />
|
||||
<div className="w-6 h-6 rounded-full shadow-sm border border-[var(--card-border)]" style={{ backgroundColor: palette.colors.sidebar_bg }} />
|
||||
<div className="w-6 h-6 rounded-full shadow-sm border border-[var(--card-border)]" style={{ backgroundColor: palette.colors.header_bg }} />
|
||||
{/* Profile Card */}
|
||||
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm">
|
||||
{/* User Header Section */}
|
||||
<div className="border-b border-(--card-border) bg-(--table-row-hover) px-6 py-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
{/* User Info */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-linear-to-br from-blue-500 to-indigo-600 text-2xl font-bold text-white shadow-lg">
|
||||
{initials}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-(--text-primary)">{fullName}</h2>
|
||||
<p className="text-sm text-(--text-secondary)">{user.email}</p>
|
||||
<div className="mt-2">{renderStatusBadge(user.status)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="font-medium text-[var(--text-primary)]">{palette.name}</span>
|
||||
<div className="md:self-center flex flex-col gap-2">
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
onClick={() => setIsPasswordModalOpen(true)}
|
||||
>
|
||||
<Key size={16} className="mr-2" />
|
||||
{t('buttons.changePassword')}
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
onClick={handleOpenEditModal}
|
||||
>
|
||||
<Pencil size={16} className="mr-2" />
|
||||
{t('buttons.editProfile')}
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentPalette?.id === palette.id && (
|
||||
<div className="absolute top-3 right-3 text-[var(--primary)]">
|
||||
<Check size={18} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{/* User Details Section */}
|
||||
<div className="p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sections.accountInfo')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* First Name */}
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
{t('fields.firstName')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
||||
{user.first_name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Last Name */}
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
{t('fields.lastName')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
||||
{user.last_name || "--"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
{t('fields.email')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Phone Number */}
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
{t('fields.phone')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
||||
{user.phone_number || "--"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tenant */}
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
{t('fields.tenant')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
||||
{user.tenant_name || "--"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
{t('fields.role')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
||||
{user.role?.role_name || "--"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Account Created */}
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
{t('fields.accountCreated')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
||||
{formatDate(user.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Last Updated */}
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
{t('fields.lastUpdated')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
||||
{formatDate(user.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Appearance / Theme Section */}
|
||||
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sections.appearance')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-6">
|
||||
{t('sections.appearanceDesc')}
|
||||
</p>
|
||||
|
||||
{isThemesLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<CustomLoader />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{palettes.map((palette) => (
|
||||
<button
|
||||
key={palette.id}
|
||||
onClick={() => setTheme(palette)}
|
||||
className={`
|
||||
relative flex items-center p-4 border rounded-xl text-left transition-all hover:shadow-md
|
||||
${currentPalette?.id === palette.id
|
||||
? "border-blue-500 bg-blue-50 ring-1 ring-blue-500"
|
||||
: "border-[var(--card-border)] bg-[var(--card-bg)] hover:border-[var(--text-secondary)]"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<h4 className={`font-medium ${currentPalette?.id === palette.id ? "text-blue-700" : "text-[var(--text-primary)]"}`}>
|
||||
{palette.name}
|
||||
</h4>
|
||||
{palette.description && (
|
||||
<p className={`text-xs mt-1 ${currentPalette?.id === palette.id ? "text-blue-600" : "text-[var(--text-secondary)]"}`}>
|
||||
{palette.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Color Preview Circles */}
|
||||
<div className="flex -space-x-2 overflow-hidden ml-4">
|
||||
<div className="inline-block h-6 w-6 rounded-full ring-2 ring-white border border-(--card-border)" style={{ backgroundColor: palette.colors.sidebar_bg }} />
|
||||
<div className="inline-block h-6 w-6 rounded-full ring-2 ring-white border border-(--card-border)" style={{ backgroundColor: palette.colors.primary }} />
|
||||
<div className="inline-block h-6 w-6 rounded-full ring-2 ring-white border border-(--card-border)" style={{ backgroundColor: palette.colors.background }} />
|
||||
</div>
|
||||
|
||||
{currentPalette?.id === palette.id && (
|
||||
<div className="absolute top-0 right-0 -mt-2 -mr-2 bg-blue-500 text-white rounded-full p-1 shadow-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Change Password Modal */}
|
||||
<CustomModal
|
||||
isOpen={isPasswordModalOpen}
|
||||
onClose={handlePasswordModalClose}
|
||||
title={t('modals.changePasswordTitle')}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
onClick={handlePasswordModalClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common:actions.cancel')}
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
type="submit"
|
||||
form="change-password-form"
|
||||
variant="primary"
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t('buttons.updatePassword')}
|
||||
</CustomButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="change-password-form" onSubmit={handlePasswordSubmit} className="space-y-4">
|
||||
<CustomInput
|
||||
label={t('modals.currentPassword')}
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
value={passwordForm.currentPassword}
|
||||
onChange={handlePasswordChange}
|
||||
required
|
||||
placeholder={t('modals.currentPasswordPlaceholder')}
|
||||
/>
|
||||
<CustomInput
|
||||
label={t('modals.newPassword')}
|
||||
name="newPassword"
|
||||
type="password"
|
||||
value={passwordForm.newPassword}
|
||||
onChange={handlePasswordChange}
|
||||
required
|
||||
placeholder={t('modals.newPasswordPlaceholder')}
|
||||
/>
|
||||
<CustomInput
|
||||
label={t('modals.confirmPassword')}
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={handlePasswordChange}
|
||||
required
|
||||
placeholder={t('modals.confirmPasswordPlaceholder')}
|
||||
/>
|
||||
|
||||
{passwordError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
|
||||
{passwordError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{passwordSuccess && (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 px-4 py-2 text-sm text-green-600">
|
||||
{passwordSuccess}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</CustomModal>
|
||||
|
||||
{/* Edit Profile Modal */}
|
||||
<CustomModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={handleEditModalClose}
|
||||
title={t('modals.editProfileTitle')}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
onClick={handleEditModalClose}
|
||||
disabled={isProfileSubmitting}
|
||||
>
|
||||
{t('common:actions.cancel')}
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
type="submit"
|
||||
form="edit-profile-form"
|
||||
variant="primary"
|
||||
loading={isProfileSubmitting}
|
||||
>
|
||||
{t('buttons.saveChanges')}
|
||||
</CustomButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="edit-profile-form" onSubmit={handleProfileSubmit} className="space-y-4">
|
||||
<CustomInput
|
||||
label={t('fields.firstName')}
|
||||
name="firstName"
|
||||
value={profileForm.firstName}
|
||||
onChange={handleProfileChange}
|
||||
required
|
||||
placeholder={t('modals.firstNamePlaceholder')}
|
||||
/>
|
||||
<CustomInput
|
||||
label={t('fields.lastName')}
|
||||
name="lastName"
|
||||
value={profileForm.lastName}
|
||||
onChange={handleProfileChange}
|
||||
placeholder={t('modals.lastNamePlaceholder')}
|
||||
/>
|
||||
<CustomInput
|
||||
label={t('fields.phone')}
|
||||
name="phoneNumber"
|
||||
type="tel"
|
||||
value={profileForm.phoneNumber}
|
||||
onChange={handleProfileChange}
|
||||
placeholder={t('modals.phonePlaceholder')}
|
||||
/>
|
||||
|
||||
{profileError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
|
||||
{profileError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profileSuccess && (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 px-4 py-2 text-sm text-green-600">
|
||||
{profileSuccess}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</CustomModal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { JSX } from "react/jsx-dev-runtime";
|
||||
|
||||
export interface PasswordForm {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export type FormatDateFunction = (value: string) => string;
|
||||
|
||||
export type FormatNameFunction = (firstName: string, lastName?: string | null) => string;
|
||||
|
||||
export type RenderStatusBadgeFunction = (status?: string) => JSX.Element;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { apiClient } from "../../lib/apiClient";
|
||||
import type {
|
||||
Role,
|
||||
RoleCreateRequest,
|
||||
RoleUpdateRequest,
|
||||
RoleAccess,
|
||||
RolePaginatedResponse,
|
||||
} from "./RolesTypes";
|
||||
|
||||
type RoleWithAccesses = Role & { accesses: RoleAccess[] };
|
||||
|
||||
const buildQueryString = (params: Record<string, any>): string => {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
const query = searchParams.toString();
|
||||
return query ? `?${query}` : "";
|
||||
};
|
||||
|
||||
export const rolesApi = {
|
||||
getAll: () => apiClient.get<Role[]>("/api/role/get"),
|
||||
|
||||
getById: (roleId: string) =>
|
||||
apiClient.get<RoleWithAccesses>(`/api/role/get/${roleId}`),
|
||||
|
||||
getAccesses: () => apiClient.get<RoleAccess[]>("/api/access/get"),
|
||||
|
||||
create: (payload: RoleCreateRequest) =>
|
||||
apiClient.post<Role>("/api/role/create", payload, { successMessage: "Role created successfully", errorMessage: "Failed to create role" }),
|
||||
|
||||
update: (roleId: string, payload: RoleUpdateRequest) =>
|
||||
apiClient.put<Role>(`/api/role/update/${roleId}`, payload, { successMessage: "Role updated successfully", errorMessage: "Failed to update role" }),
|
||||
|
||||
remove: (roleId: string) =>
|
||||
apiClient.delete<{ message: string }>(`/api/role/delete/${roleId}`, { successMessage: "Role deleted", errorMessage: "Failed to delete role" }),
|
||||
|
||||
getPaginated: (params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
}) => {
|
||||
const queryString = buildQueryString({
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
search: params.search,
|
||||
});
|
||||
return apiClient.get<RolePaginatedResponse>(`/api/role/list${queryString}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
export type Role = {
|
||||
id: string;
|
||||
role_name: string;
|
||||
tenant_id?: string | null;
|
||||
tenant_name?: string | null;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type RoleAccess = {
|
||||
id: string;
|
||||
access_code: string;
|
||||
category: string;
|
||||
name: string;
|
||||
parent_id?: string | null;
|
||||
};
|
||||
|
||||
export type RoleCreateRequest = {
|
||||
role_name: string;
|
||||
tenant_id?: string;
|
||||
access_ids?: string[];
|
||||
is_default?: boolean;
|
||||
};
|
||||
|
||||
export type RoleUpdateRequest = {
|
||||
role_name?: string;
|
||||
access_ids?: string[] | null;
|
||||
is_default?: boolean;
|
||||
};
|
||||
|
||||
export type RolePaginatedResponse = {
|
||||
items: Role[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
CustomButton,
|
||||
CustomDropdown,
|
||||
CustomInput,
|
||||
CustomBackButton,
|
||||
|
||||
} from "../../../components/custom";
|
||||
import { rolesApi } from "../RolesApi";
|
||||
import type { RoleAccess, RoleCreateRequest } from "../RolesTypes";
|
||||
import { tenantsApi } from "../../tenants/TenantsApi";
|
||||
import type { Tenant } from "../../tenants/TenantsTypes";
|
||||
import { useAuth } from "../../../context/AuthContext";
|
||||
import { GroupedAccessSelector } from "./GroupedAccessSelector";
|
||||
|
||||
const AddRoles = () => {
|
||||
const { t } = useTranslation(['roles', 'common']);
|
||||
const { user, hasAccess, isLoading: isAuthLoading } = useAuth();
|
||||
const canReadAllTenants = hasAccess("superadmin.tenant.read");
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState<RoleCreateRequest>({
|
||||
role_name: "",
|
||||
tenant_id: "",
|
||||
access_ids: [],
|
||||
is_default: false,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [accessOptions, setAccessOptions] = useState<RoleAccess[]>([]);
|
||||
const [isAccessLoading, setIsAccessLoading] = useState(false);
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [isTenantLoading, setIsTenantLoading] = useState(false);
|
||||
const [currentTenantName, setCurrentTenantName] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
const loadAccesses = async () => {
|
||||
setIsAccessLoading(true);
|
||||
try {
|
||||
const data = await rolesApi.getAccesses();
|
||||
if (isMounted) {
|
||||
setAccessOptions(data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMounted) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to load access options.";
|
||||
setErrorMessage(message);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsAccessLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadTenants = async () => {
|
||||
if (!canReadAllTenants) {
|
||||
setIsTenantLoading(true);
|
||||
try {
|
||||
if (user?.tenant_id && user?.tenant_name) {
|
||||
if (isMounted) {
|
||||
setCurrentTenantName(user.tenant_name);
|
||||
setTenants([]);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tenant_id: user.tenant_id ?? "",
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await tenantsApi.getMine();
|
||||
if (isMounted) {
|
||||
setCurrentTenantName(data.tenant_name);
|
||||
setTenants([]);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tenant_id: data.id,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMounted) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to load tenant.";
|
||||
setErrorMessage(message);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tenant_id: user?.tenant_id ?? "",
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsTenantLoading(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTenantLoading(true);
|
||||
try {
|
||||
const data = await tenantsApi.getAll();
|
||||
if (isMounted) {
|
||||
setTenants(data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMounted) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to load tenants.";
|
||||
setErrorMessage(message);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsTenantLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadAccesses();
|
||||
loadTenants();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [canReadAllTenants, isAuthLoading, user?.tenant_id, user?.tenant_name]);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = event.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Filter accessOptions to only show what the current user has access to
|
||||
const availableAccessOptions = useMemo(() => {
|
||||
if (!user?.role?.accesses) return [];
|
||||
// If the user has access codes in their token, filter the list
|
||||
return accessOptions.filter((option) =>
|
||||
user.role?.accesses.includes(option.access_code)
|
||||
);
|
||||
}, [accessOptions, user]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const payload: RoleCreateRequest = {
|
||||
role_name: formData.role_name.trim(),
|
||||
tenant_id: formData.tenant_id?.trim() || undefined,
|
||||
access_ids: formData.access_ids ?? [],
|
||||
is_default: formData.is_default,
|
||||
};
|
||||
|
||||
await rolesApi.create(payload);
|
||||
navigate("/roles");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unable to add role.";
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<CustomBackButton to="/roles" tooltip={t('backToRoles')} />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">{t('add')}</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('subTitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-lg border border-gray-200 bg-white p-6"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<CustomInput
|
||||
label={t('fields.roleName')}
|
||||
name="role_name"
|
||||
placeholder={t('fields.roleNamePlaceholder')}
|
||||
value={formData.role_name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
{canReadAllTenants ? (
|
||||
<CustomDropdown
|
||||
label={t('fields.tenant')}
|
||||
name="tenant_id"
|
||||
placeholder={
|
||||
isTenantLoading
|
||||
? t('fields.tenantLoading')
|
||||
: t('fields.tenantPlaceholder')
|
||||
}
|
||||
value={formData.tenant_id ?? ""}
|
||||
onChange={(event) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tenant_id: event.target.value || undefined,
|
||||
}))
|
||||
}
|
||||
options={tenants.map((tenant) => ({
|
||||
label: tenant.tenant_name,
|
||||
value: tenant.id,
|
||||
}))}
|
||||
disabled={isTenantLoading}
|
||||
/>
|
||||
) : (
|
||||
<CustomInput
|
||||
label={t('fields.tenant')}
|
||||
name="tenant_name"
|
||||
value={currentTenantName || user?.tenant_name || user?.tenant_id || ""}
|
||||
placeholder={isTenantLoading ? t('fields.tenantLoading') : t('fields.tenantAssigned')}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canReadAllTenants && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_default"
|
||||
name="is_default"
|
||||
checked={formData.is_default || false}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, is_default: e.target.checked }))
|
||||
}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<label htmlFor="is_default" className="text-sm font-medium text-gray-700">
|
||||
{t('fields.isDefault')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GroupedAccessSelector
|
||||
allAccesses={availableAccessOptions}
|
||||
selectedIds={formData.access_ids || []}
|
||||
onChange={(ids) =>
|
||||
setFormData((prev) => ({ ...prev, access_ids: ids }))
|
||||
}
|
||||
isLoading={isAccessLoading}
|
||||
/>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<CustomButton type="submit" variant="primary" loading={isLoading}>
|
||||
{t('actions.create')}
|
||||
</CustomButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddRoles;
|
||||
@@ -0,0 +1,519 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Edit2, Eye, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
CustomButton,
|
||||
CustomConfirmationModal,
|
||||
CustomInput,
|
||||
CustomModal,
|
||||
CustomTable,
|
||||
CustomLoader,
|
||||
CustomActionMenu,
|
||||
CustomActionItem,
|
||||
} from "../../../components/custom";
|
||||
import { GroupedAccessSelector } from "./GroupedAccessSelector";
|
||||
import { GroupedAccessViewer } from "./GroupedAccessViewer";
|
||||
import type { ColumnDef } from "../../../components/custom/CustomTable";
|
||||
import type {
|
||||
Role,
|
||||
RoleAccess,
|
||||
RoleUpdateRequest,
|
||||
RolePaginatedResponse,
|
||||
} from "../RolesTypes";
|
||||
import { rolesApi } from "../RolesApi";
|
||||
import { tenantsApi } from "../../tenants/TenantsApi";
|
||||
import type { Tenant } from "../../tenants/TenantsTypes";
|
||||
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
|
||||
import { useAuth } from "../../../context/AuthContext";
|
||||
import { useDebounce } from "../../../components/hooks/useDebounce";
|
||||
|
||||
const formatDate = (dateString?: string | null, language: string = 'en') => {
|
||||
if (!dateString) return "";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return dateString;
|
||||
|
||||
const hasTime = dateString.includes("T") || dateString.includes(":");
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
};
|
||||
|
||||
if (hasTime) {
|
||||
options.hour = "2-digit";
|
||||
options.minute = "2-digit";
|
||||
options.hour12 = false;
|
||||
}
|
||||
|
||||
return date.toLocaleString(language === 'ar' ? 'ar-EG' : 'en-GB', options);
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const AllRoles = () => {
|
||||
const { t, i18n } = useTranslation(['roles', 'common']);
|
||||
const { user: currentUser, hasAccess, isLoading: isAuthLoading } = useAuth();
|
||||
const canReadAllTenants = hasAccess("superadmin.tenant.read");
|
||||
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [search, setSearch] = useState("");
|
||||
const [totalRows, setTotalRows] = useState(0);
|
||||
const [, setTotalPages] = useState(0);
|
||||
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const prevLoadingRef = useRef(isLoading);
|
||||
|
||||
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
|
||||
const [selectedRoleAccesses, setSelectedRoleAccesses] = useState<RoleAccess[] | null>(null);
|
||||
const [isViewOpen, setIsViewOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [isDetailLoading, setIsDetailLoading] = useState(false);
|
||||
|
||||
const [accessOptions, setAccessOptions] = useState<RoleAccess[]>([]);
|
||||
const [isAccessLoading, setIsAccessLoading] = useState(false);
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null);
|
||||
|
||||
const [editForm, setEditForm] = useState<{ role_name: string; is_default?: boolean }>({ role_name: "" });
|
||||
const [editAccessIds, setEditAccessIds] = useState<string[]>([]);
|
||||
const [editError, setEditError] = useState("");
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Filter access options based on current user's permissions
|
||||
const availableAccessOptions = useMemo(() => {
|
||||
if (
|
||||
!currentUser ||
|
||||
!currentUser.role ||
|
||||
!currentUser.role.accesses ||
|
||||
!Array.isArray(currentUser.role.accesses)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return accessOptions.filter((opt) =>
|
||||
currentUser.role!.accesses.includes(opt.access_code)
|
||||
);
|
||||
}, [accessOptions, currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadSupportingData = async () => {
|
||||
setIsAccessLoading(true);
|
||||
try {
|
||||
const accesses = await rolesApi.getAccesses();
|
||||
if (isMounted) setAccessOptions(accesses);
|
||||
|
||||
if (canReadAllTenants) {
|
||||
const tenantsData = await tenantsApi.getAll();
|
||||
if (isMounted) setTenants(tenantsData);
|
||||
} else if (currentUser?.tenant_id) {
|
||||
const tenant = await tenantsApi.getMine();
|
||||
if (isMounted) setCurrentTenant(tenant);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load supporting data:", err);
|
||||
} finally {
|
||||
if (isMounted) setIsAccessLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRoles = async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
const response: RolePaginatedResponse = await rolesApi.getPaginated({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
search: debouncedSearch || undefined,
|
||||
});
|
||||
|
||||
if (isMounted) {
|
||||
setRoles(response.items);
|
||||
setTotalRows(response.total);
|
||||
setTotalPages(response.total_pages);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted) {
|
||||
const msg = err instanceof Error ? err.message : t('messages.error');
|
||||
setErrorMessage(msg);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAuthLoading) {
|
||||
loadSupportingData();
|
||||
loadRoles();
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [
|
||||
isAuthLoading,
|
||||
canReadAllTenants,
|
||||
currentUser?.tenant_id,
|
||||
page,
|
||||
pageSize,
|
||||
debouncedSearch,
|
||||
t
|
||||
]);
|
||||
|
||||
// Reset page when search changes
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
// Restore search focus after loading
|
||||
useEffect(() => {
|
||||
if (prevLoadingRef.current && !isLoading && search.trim()) {
|
||||
searchInputRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
prevLoadingRef.current = isLoading;
|
||||
}, [isLoading, search]);
|
||||
|
||||
const getTenantName = useCallback(
|
||||
(tenantId?: string | null) => {
|
||||
if (!tenantId) return "";
|
||||
const found = tenants.find((t) => t.id === tenantId);
|
||||
if (found) return found.tenant_name;
|
||||
if (currentTenant?.id === tenantId) return currentTenant.tenant_name;
|
||||
if (currentUser?.tenant_id === tenantId && currentUser?.tenant_name) {
|
||||
return currentUser.tenant_name;
|
||||
}
|
||||
return tenantId;
|
||||
},
|
||||
[tenants, currentTenant, currentUser]
|
||||
);
|
||||
|
||||
const openView = useCallback(async (role: Role) => {
|
||||
setSelectedRole(role);
|
||||
setSelectedRoleAccesses(null);
|
||||
setIsViewOpen(true);
|
||||
setIsDetailLoading(true);
|
||||
try {
|
||||
const details = await rolesApi.getById(role.id);
|
||||
setSelectedRoleAccesses(details.accesses || []);
|
||||
} catch {
|
||||
setSelectedRoleAccesses([]);
|
||||
} finally {
|
||||
setIsDetailLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback(async (role: Role) => {
|
||||
setSelectedRole(role);
|
||||
setEditForm({ role_name: role.role_name, is_default: role.is_default });
|
||||
setEditAccessIds([]);
|
||||
setEditError("");
|
||||
setIsEditOpen(true);
|
||||
try {
|
||||
const details = await rolesApi.getById(role.id);
|
||||
setEditAccessIds(details.accesses?.map((a) => a.id) || []);
|
||||
} catch (err) {
|
||||
setEditError(err instanceof Error ? err.message : t('messages.error'));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const openDelete = useCallback((role: Role) => {
|
||||
setSelectedRole(role);
|
||||
setDeleteError("");
|
||||
setIsDeleteOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeView = useCallback(() => {
|
||||
setIsViewOpen(false);
|
||||
setSelectedRole(null);
|
||||
setSelectedRoleAccesses(null);
|
||||
}, []);
|
||||
|
||||
const closeEdit = useCallback(() => {
|
||||
setIsEditOpen(false);
|
||||
setSelectedRole(null);
|
||||
setEditError("");
|
||||
}, []);
|
||||
|
||||
const closeDelete = useCallback(() => {
|
||||
setIsDeleteOpen(false);
|
||||
setSelectedRole(null);
|
||||
setDeleteError("");
|
||||
}, []);
|
||||
|
||||
const handleEditChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
[name]: type === "checkbox" ? checked : value,
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleUpdate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedRole) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setEditError("");
|
||||
|
||||
try {
|
||||
const payload: RoleUpdateRequest = {
|
||||
role_name: editForm.role_name.trim(),
|
||||
access_ids: editAccessIds.length ? editAccessIds : null,
|
||||
is_default: editForm.is_default,
|
||||
};
|
||||
|
||||
const updated = await rolesApi.update(selectedRole.id, payload);
|
||||
setRoles((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
|
||||
closeEdit();
|
||||
} catch (err) {
|
||||
setEditError(err instanceof Error ? err.message : t('messages.error'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedRole) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await rolesApi.remove(selectedRole.id);
|
||||
setRoles((prev) => prev.filter((r) => r.id !== selectedRole.id));
|
||||
closeDelete();
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : t('messages.error'));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo<Array<ColumnDef<Role>>>(
|
||||
() => [
|
||||
{ key: "role_name", header: t('columns.roleName') },
|
||||
{
|
||||
key: "tenant_id",
|
||||
header: t('columns.tenant'),
|
||||
render: (row) => getTenantName(row.tenant_id),
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
header: t('columns.created'),
|
||||
render: (row) => (
|
||||
<div className="text-sm font-medium text-(--text-primary)">
|
||||
{formatDate(row.created_at, i18n.language)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updated_at",
|
||||
header: t('columns.updated'),
|
||||
render: (row) => (
|
||||
<div className="text-sm font-medium text-(--text-primary)">
|
||||
{formatDate(row.updated_at, i18n.language)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "id",
|
||||
header: t('columns.actions'),
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<CustomActionMenu>
|
||||
<ProtectedComponent requiredAccess="admin.role.read">
|
||||
<CustomActionItem onClick={() => openView(row)}>
|
||||
<Eye size={16} className="mr-2" /> {t('actions.view')}
|
||||
</CustomActionItem>
|
||||
</ProtectedComponent>
|
||||
|
||||
<ProtectedComponent requiredAccess="admin.role.update">
|
||||
{(!row.is_default || canReadAllTenants) && (
|
||||
<CustomActionItem onClick={() => openEdit(row)}>
|
||||
<Edit2 size={16} className="mr-2" /> {t('actions.edit')}
|
||||
</CustomActionItem>
|
||||
)}
|
||||
</ProtectedComponent>
|
||||
|
||||
<ProtectedComponent requiredAccess="admin.role.delete">
|
||||
{(!row.is_default || canReadAllTenants) && (
|
||||
<CustomActionItem onClick={() => openDelete(row)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
|
||||
<Trash2 size={16} className="mr-2" /> {t('actions.delete')}
|
||||
</CustomActionItem>
|
||||
)}
|
||||
</ProtectedComponent>
|
||||
</CustomActionMenu>
|
||||
),
|
||||
},
|
||||
],
|
||||
[getTenantName, openView, openEdit, openDelete, t, i18n.language]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">{t('title')}</h1>
|
||||
</div>
|
||||
<ProtectedComponent requiredAccess="admin.role.create">
|
||||
<Link to="/roles/add">
|
||||
<CustomButton variant="primary">+ {t('add')}</CustomButton>
|
||||
</Link>
|
||||
</ProtectedComponent>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-6">
|
||||
<CustomLoader />
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : (
|
||||
<CustomTable
|
||||
data={roles}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.id}
|
||||
manualPagination
|
||||
manualFiltering
|
||||
totalRows={totalRows}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
onSearchChange={setSearch}
|
||||
searchInputRef={searchInputRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* View Modal */}
|
||||
<CustomModal
|
||||
isOpen={isViewOpen}
|
||||
onClose={closeView}
|
||||
title={t('details')}
|
||||
size="lg"
|
||||
footer={<CustomButton variant="outlined" onClick={closeView}>{t('actions.close')}</CustomButton>}
|
||||
>
|
||||
{selectedRole ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.roleName')}</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{selectedRole.role_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.tenant')}</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{getTenantName(selectedRole.tenant_id)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.created')}</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{formatDate(selectedRole.created_at, i18n.language)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.updated')}</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{formatDate(selectedRole.updated_at, i18n.language)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)] mb-2">Accesses</p>
|
||||
{isDetailLoading ? (
|
||||
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-3 text-sm text-[var(--text-secondary)]">
|
||||
<CustomLoader />
|
||||
</div>
|
||||
) : (
|
||||
<GroupedAccessViewer accesses={selectedRoleAccesses || []} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('common.noData')}</p>
|
||||
)}
|
||||
</CustomModal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<CustomModal
|
||||
isOpen={isEditOpen}
|
||||
onClose={closeEdit}
|
||||
title={t('edit')}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<CustomButton variant="outlined" onClick={closeEdit} disabled={isSaving}>{t('actions.cancel')}</CustomButton>
|
||||
<CustomButton type="submit" form="edit-role-form" variant="primary" loading={isSaving}>{t('update')}</CustomButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="edit-role-form" onSubmit={handleUpdate} className="space-y-6">
|
||||
<CustomInput
|
||||
label={t('columns.roleName')}
|
||||
name="role_name"
|
||||
value={editForm.role_name}
|
||||
onChange={handleEditChange}
|
||||
required
|
||||
/>
|
||||
|
||||
{canReadAllTenants && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="edit_is_default"
|
||||
name="is_default"
|
||||
checked={editForm.is_default || false}
|
||||
onChange={handleEditChange}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<label htmlFor="edit_is_default" className="text-sm font-medium text-[var(--text-primary)]">
|
||||
Set as Default Role
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GroupedAccessSelector
|
||||
allAccesses={availableAccessOptions}
|
||||
selectedIds={editAccessIds}
|
||||
onChange={setEditAccessIds}
|
||||
isLoading={isAccessLoading}
|
||||
/>
|
||||
{editError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
|
||||
{editError}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</CustomModal>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<CustomConfirmationModal
|
||||
isOpen={isDeleteOpen}
|
||||
onClose={closeDelete}
|
||||
onConfirm={handleDelete}
|
||||
title={t('messages.confirmDelete')}
|
||||
description={deleteError || t('messages.confirmDelete')}
|
||||
confirmText={t('actions.delete')}
|
||||
variant="danger"
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllRoles;
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useMemo } from "react";
|
||||
import type { RoleAccess } from "../RolesTypes";
|
||||
import { CustomLoader } from "../../../components/custom";
|
||||
|
||||
interface GroupedAccessSelectorProps {
|
||||
allAccesses: RoleAccess[];
|
||||
selectedIds: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface CategoryGroup {
|
||||
category: string;
|
||||
accesses: RoleAccess[];
|
||||
children: Record<string, CategoryGroup>;
|
||||
}
|
||||
|
||||
export const GroupedAccessSelector = ({
|
||||
allAccesses,
|
||||
selectedIds = [],
|
||||
onChange,
|
||||
isLoading = false,
|
||||
}: GroupedAccessSelectorProps) => {
|
||||
const hierarchicalGroups = useMemo(() => {
|
||||
const parents: RoleAccess[] = [];
|
||||
const children: Record<string, RoleAccess[]> = {};
|
||||
|
||||
allAccesses.forEach(access => {
|
||||
if (!access.parent_id) {
|
||||
parents.push(access);
|
||||
} else {
|
||||
if (!children[access.parent_id]) {
|
||||
children[access.parent_id] = [];
|
||||
}
|
||||
children[access.parent_id].push(access);
|
||||
}
|
||||
});
|
||||
|
||||
const categoryGroups: Record<string, CategoryGroup> = {};
|
||||
|
||||
parents.forEach(parent => {
|
||||
if (!categoryGroups[parent.category]) {
|
||||
categoryGroups[parent.category] = {
|
||||
category: parent.category,
|
||||
accesses: [],
|
||||
children: {}
|
||||
};
|
||||
}
|
||||
|
||||
categoryGroups[parent.category].accesses.push(parent);
|
||||
|
||||
const parentChildren = children[parent.id] || [];
|
||||
parentChildren.forEach(child => {
|
||||
if (!categoryGroups[parent.category].children[child.category]) {
|
||||
categoryGroups[parent.category].children[child.category] = {
|
||||
category: child.category,
|
||||
accesses: [],
|
||||
children: {}
|
||||
};
|
||||
}
|
||||
categoryGroups[parent.category].children[child.category].accesses.push(child);
|
||||
});
|
||||
});
|
||||
|
||||
return categoryGroups;
|
||||
}, [allAccesses]);
|
||||
|
||||
const getCategoryAccesses = (group: CategoryGroup): RoleAccess[] => {
|
||||
const accesses = [...group.accesses];
|
||||
Object.values(group.children).forEach(child => {
|
||||
accesses.push(...child.accesses);
|
||||
});
|
||||
return accesses;
|
||||
};
|
||||
|
||||
const isCategorySelected = (group: CategoryGroup) => {
|
||||
const categoryAccesses = getCategoryAccesses(group);
|
||||
return (
|
||||
categoryAccesses.length > 0 &&
|
||||
categoryAccesses.every((access) => selectedIds.includes(access.id))
|
||||
);
|
||||
};
|
||||
|
||||
const isCategoryIndeterminate = (group: CategoryGroup) => {
|
||||
const categoryAccesses = getCategoryAccesses(group);
|
||||
const selectedCount = categoryAccesses.filter((access) =>
|
||||
selectedIds.includes(access.id)
|
||||
).length;
|
||||
return selectedCount > 0 && selectedCount < categoryAccesses.length;
|
||||
};
|
||||
|
||||
const toggleCategory = (group: CategoryGroup) => {
|
||||
const categoryAccesses = getCategoryAccesses(group);
|
||||
const allSelected = isCategorySelected(group);
|
||||
const categoryIds = categoryAccesses.map((a) => a.id);
|
||||
|
||||
let newIds: string[];
|
||||
if (allSelected) {
|
||||
newIds = selectedIds.filter((id) => !categoryIds.includes(id));
|
||||
} else {
|
||||
const uniqueIds = new Set([...selectedIds, ...categoryIds]);
|
||||
newIds = Array.from(uniqueIds);
|
||||
}
|
||||
onChange(newIds);
|
||||
};
|
||||
|
||||
// --- Subcategory Helpers ---
|
||||
const isSubcategorySelected = (accesses: RoleAccess[]) => {
|
||||
return (
|
||||
accesses.length > 0 &&
|
||||
accesses.every((access) => selectedIds.includes(access.id))
|
||||
);
|
||||
};
|
||||
|
||||
const isSubcategoryIndeterminate = (accesses: RoleAccess[]) => {
|
||||
const selectedCount = accesses.filter((access) =>
|
||||
selectedIds.includes(access.id)
|
||||
).length;
|
||||
return selectedCount > 0 && selectedCount < accesses.length;
|
||||
};
|
||||
|
||||
const toggleSubcategory = (accesses: RoleAccess[]) => {
|
||||
const allSelected = isSubcategorySelected(accesses);
|
||||
const accessIds = accesses.map((a) => a.id);
|
||||
|
||||
let newIds: string[];
|
||||
if (allSelected) {
|
||||
newIds = selectedIds.filter((id) => !accessIds.includes(id));
|
||||
} else {
|
||||
const uniqueIds = new Set([...selectedIds, ...accessIds]);
|
||||
newIds = Array.from(uniqueIds);
|
||||
}
|
||||
onChange(newIds);
|
||||
};
|
||||
|
||||
// --- Global Helpers ---
|
||||
const isAllSelected = () => {
|
||||
return (
|
||||
allAccesses.length > 0 &&
|
||||
allAccesses.every((access) => selectedIds.includes(access.id))
|
||||
);
|
||||
};
|
||||
|
||||
const isAllIndeterminate = () => {
|
||||
const selectedCount = selectedIds.length;
|
||||
return selectedCount > 0 && selectedCount < allAccesses.length;
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (isAllSelected()) {
|
||||
onChange([]);
|
||||
} else {
|
||||
const allIds = allAccesses.map((a) => a.id);
|
||||
onChange(allIds);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Individual Helper ---
|
||||
const toggleAccess = (accessId: string) => {
|
||||
const newIds = selectedIds.includes(accessId)
|
||||
? selectedIds.filter((id) => id !== accessId)
|
||||
: [...selectedIds, accessId];
|
||||
onChange(newIds);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <CustomLoader />;
|
||||
}
|
||||
|
||||
if (allAccesses.length === 0) {
|
||||
return <p className="text-sm text-gray-500">No permissions available.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-medium text-gray-900">Permissions</h3>
|
||||
|
||||
{/* Global Select All */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="select-all"
|
||||
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
checked={isAllSelected()}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = isAllIndeterminate();
|
||||
}}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
<label
|
||||
htmlFor="select-all"
|
||||
className="text-sm font-medium text-gray-700 cursor-pointer select-none"
|
||||
>
|
||||
Select All Permissions
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{Object.entries(hierarchicalGroups).map(([category, group]) => (
|
||||
<div
|
||||
key={category}
|
||||
className="rounded-lg border border-gray-200 bg-gray-50/50 p-4"
|
||||
>
|
||||
{/* Category Header */}
|
||||
<div className="mb-4 flex items-center gap-3 border-b border-gray-200 pb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
checked={isCategorySelected(group)}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = isCategoryIndeterminate(group);
|
||||
}}
|
||||
onChange={() => toggleCategory(group)}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-gray-800 uppercase tracking-wide">
|
||||
{category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Parent Accesses */}
|
||||
{group.accesses.length > 0 && (
|
||||
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{group.accesses.map((access) => (
|
||||
<label
|
||||
key={access.id}
|
||||
className="flex items-start gap-3 cursor-pointer group"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer group-hover:border-blue-400"
|
||||
checked={selectedIds.includes(access.id)}
|
||||
onChange={() => toggleAccess(access.id)}
|
||||
/>
|
||||
<span className="text-sm text-gray-600 group-hover:text-gray-900">
|
||||
{access.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Child Categories */}
|
||||
{Object.keys(group.children).length > 0 && (
|
||||
<div className="space-y-4 mt-4">
|
||||
{Object.entries(group.children).map(([childCategory, childGroup]) => (
|
||||
<div
|
||||
key={childCategory}
|
||||
className="rounded-md border border-gray-300 bg-white p-3 ml-4"
|
||||
>
|
||||
{/* Subcategory Header */}
|
||||
<div className="mb-3 flex items-center gap-2 border-b border-gray-200 pb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
checked={isSubcategorySelected(childGroup.accesses)}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = isSubcategoryIndeterminate(childGroup.accesses);
|
||||
}}
|
||||
onChange={() => toggleSubcategory(childGroup.accesses)}
|
||||
/>
|
||||
<span className="text-xs font-semibold text-gray-700 uppercase tracking-wide">
|
||||
{childCategory}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Subcategory Accesses */}
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{childGroup.accesses.map((access) => (
|
||||
<label
|
||||
key={access.id}
|
||||
className="flex items-start gap-2 cursor-pointer group"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 size-3.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer group-hover:border-blue-400"
|
||||
checked={selectedIds.includes(access.id)}
|
||||
onChange={() => toggleAccess(access.id)}
|
||||
/>
|
||||
<span className="text-xs text-gray-600 group-hover:text-gray-900">
|
||||
{access.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useMemo } from "react";
|
||||
import type { RoleAccess } from "../RolesTypes";
|
||||
|
||||
interface GroupedAccessViewerProps {
|
||||
accesses: RoleAccess[];
|
||||
}
|
||||
|
||||
interface CategoryGroup {
|
||||
category: string;
|
||||
accesses: RoleAccess[];
|
||||
children: Record<string, CategoryGroup>;
|
||||
}
|
||||
|
||||
export const GroupedAccessViewer = ({ accesses }: GroupedAccessViewerProps) => {
|
||||
const hierarchicalGroups = useMemo(() => {
|
||||
const parents: RoleAccess[] = [];
|
||||
const children: Record<string, RoleAccess[]> = {};
|
||||
|
||||
accesses.forEach(access => {
|
||||
if (!access.parent_id) {
|
||||
parents.push(access);
|
||||
} else {
|
||||
if (!children[access.parent_id]) {
|
||||
children[access.parent_id] = [];
|
||||
}
|
||||
children[access.parent_id].push(access);
|
||||
}
|
||||
});
|
||||
|
||||
const categoryGroups: Record<string, CategoryGroup> = {};
|
||||
|
||||
parents.forEach(parent => {
|
||||
if (!categoryGroups[parent.category]) {
|
||||
categoryGroups[parent.category] = {
|
||||
category: parent.category,
|
||||
accesses: [],
|
||||
children: {}
|
||||
};
|
||||
}
|
||||
|
||||
categoryGroups[parent.category].accesses.push(parent);
|
||||
|
||||
const parentChildren = children[parent.id] || [];
|
||||
parentChildren.forEach(child => {
|
||||
if (!categoryGroups[parent.category].children[child.category]) {
|
||||
categoryGroups[parent.category].children[child.category] = {
|
||||
category: child.category,
|
||||
accesses: [],
|
||||
children: {}
|
||||
};
|
||||
}
|
||||
categoryGroups[parent.category].children[child.category].accesses.push(child);
|
||||
});
|
||||
});
|
||||
|
||||
return categoryGroups;
|
||||
}, [accesses]);
|
||||
|
||||
if (!accesses || accesses.length === 0) {
|
||||
return <p className="text-sm text-gray-500">No permissions assigned.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(hierarchicalGroups).map(([category, group]) => (
|
||||
<div key={category} className="rounded-lg border border-gray-200 bg-gray-50/50 p-4">
|
||||
<h4 className="mb-3 text-sm font-semibold text-gray-800 uppercase tracking-wide border-b border-gray-200 pb-2">
|
||||
{category}
|
||||
</h4>
|
||||
|
||||
{group.accesses.length > 0 && (
|
||||
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 mb-3">
|
||||
{group.accesses.map((access) => (
|
||||
<li key={access.id} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<span className="mt-1.5 size-1.5 rounded-full bg-blue-500 shrink-0" />
|
||||
<span>{access.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{Object.keys(group.children).length > 0 && (
|
||||
<div className="space-y-3 mt-3">
|
||||
{Object.entries(group.children).map(([childCategory, childGroup]) => (
|
||||
<div key={childCategory} className="rounded-md border border-gray-300 bg-white p-3 ml-4">
|
||||
<h5 className="mb-2 text-xs font-semibold text-gray-700 uppercase tracking-wide border-b border-gray-200 pb-1">
|
||||
{childCategory}
|
||||
</h5>
|
||||
<ul className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||
{childGroup.accesses.map((access) => (
|
||||
<li key={access.id} className="flex items-start gap-1.5 text-xs text-gray-600">
|
||||
<span className="mt-1 size-1 rounded-full bg-green-500 shrink-0" />
|
||||
<span>{access.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import AllRoles from "./components/AllRoles";
|
||||
import AddRoles from "./components/AddRoles";
|
||||
|
||||
const Roles: React.FC = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<AllRoles />} />
|
||||
<Route path="add" element={<AddRoles />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default Roles;
|
||||
@@ -0,0 +1,57 @@
|
||||
// src/features/Tenants/TenantsApi.ts
|
||||
import { apiClient } from "../../lib/apiClient";
|
||||
import type {
|
||||
ApiMessage,
|
||||
Tenant,
|
||||
TenantCreateRequest,
|
||||
TenantPaginatedResponse,
|
||||
TenantUpdateRequest,
|
||||
} from "./TenantsTypes";
|
||||
|
||||
// Helper to build URL with query params
|
||||
const buildQueryString = (params: Record<string, any>): string => {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
const query = searchParams.toString();
|
||||
return query ? `?${query}` : "";
|
||||
};
|
||||
|
||||
export const tenantsApi = {
|
||||
getAll: () => apiClient.get<Tenant[]>("/api/tenant/get"),
|
||||
|
||||
getMine: () => apiClient.get<Tenant>("/api/tenant/me"),
|
||||
|
||||
getById: (tenantId: string) =>
|
||||
apiClient.get<Tenant>(`/api/tenant/get/${tenantId}`),
|
||||
|
||||
create: (payload: TenantCreateRequest) =>
|
||||
apiClient.post<Tenant>("/api/tenant/create", payload, { successMessage: "Tenant created successfully", errorMessage: "Failed to create tenant" }),
|
||||
|
||||
update: (tenantId: string, payload: TenantUpdateRequest) =>
|
||||
apiClient.put<Tenant>(`/api/tenant/update/${tenantId}`, payload, { successMessage: "Tenant updated successfully", errorMessage: "Failed to update tenant" }),
|
||||
|
||||
remove: (tenantId: string) =>
|
||||
apiClient.delete<ApiMessage>(`/api/tenant/delete/${tenantId}`, { successMessage: "Tenant deleted", errorMessage: "Failed to delete tenant" }),
|
||||
|
||||
// Fixed: Manually append query params since apiClient doesn't support { params }
|
||||
getPaginated: (params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
is_active?: boolean | null;
|
||||
}) => {
|
||||
const queryString = buildQueryString({
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
search: params.search,
|
||||
is_active: params.is_active,
|
||||
});
|
||||
return apiClient.get<TenantPaginatedResponse>(
|
||||
`/api/tenant/list${queryString}`
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
export type Tenant = {
|
||||
id: string;
|
||||
tenant_name: string;
|
||||
tenant_domain: string;
|
||||
tenant_logo_url?: string | null;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type TenantCreateRequest = {
|
||||
tenant_name: string;
|
||||
tenant_domain: string;
|
||||
tenant_logo_url?: string;
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
export type TenantUpdateRequest = {
|
||||
tenant_name?: string;
|
||||
tenant_domain?: string;
|
||||
tenant_logo_url?: string | null;
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
export type TenantPaginatedResponse = {
|
||||
items: Tenant[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
};
|
||||
|
||||
export type ApiMessage = {
|
||||
message?: string;
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CustomButton, CustomInput } from "../../../components/custom";
|
||||
import CustomBackButton from "../../../components/custom/CustomBackButton";
|
||||
import { CustomLoader } from "../../../components/custom";
|
||||
import { tenantsApi } from "../TenantsApi";
|
||||
import type { Tenant, TenantCreateRequest } from "../TenantsTypes";
|
||||
import { useAuth } from "../../../context/AuthContext";
|
||||
|
||||
const AddTenants = () => {
|
||||
const { hasAccess, isLoading: isAuthLoading } = useAuth();
|
||||
const canCreateTenant = hasAccess("superadmin.tenant.create");
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState<TenantCreateRequest>({
|
||||
tenant_name: "",
|
||||
tenant_domain: "",
|
||||
tenant_logo_url: "",
|
||||
});
|
||||
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null);
|
||||
const [isTenantLoading, setIsTenantLoading] = useState(true);
|
||||
const [tenantError, setTenantError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthLoading || canCreateTenant) {
|
||||
setIsTenantLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
const loadTenant = async () => {
|
||||
setTenantError("");
|
||||
setIsTenantLoading(true);
|
||||
try {
|
||||
const data = await tenantsApi.getMine();
|
||||
if (isMounted) {
|
||||
setCurrentTenant(data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMounted) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to load tenant.";
|
||||
setTenantError(message);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsTenantLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadTenant();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [canCreateTenant, isAuthLoading]);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = event.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const payload: TenantCreateRequest = {
|
||||
tenant_name: formData.tenant_name.trim(),
|
||||
tenant_domain: formData.tenant_domain.trim(),
|
||||
tenant_logo_url: formData.tenant_logo_url?.trim() || undefined,
|
||||
};
|
||||
|
||||
await tenantsApi.create(payload);
|
||||
navigate("/tenants");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unable to add tenant.";
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<CustomBackButton to="/tenants" tooltip="Back to Tenants" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">
|
||||
{canCreateTenant ? "Add Tenant" : "Tenant Details"}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{canCreateTenant
|
||||
? "Create a new tenant with domain and logo details."
|
||||
: "Your account is assigned to this tenant."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canCreateTenant ? (
|
||||
isAuthLoading || isTenantLoading ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 relative">
|
||||
<CustomLoader />
|
||||
</div>
|
||||
) : tenantError ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
|
||||
{tenantError}
|
||||
</div>
|
||||
) : currentTenant ? (
|
||||
<div className="space-y-6 rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<CustomInput
|
||||
label="Tenant Name"
|
||||
value={currentTenant.tenant_name}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
<CustomInput
|
||||
label="Tenant Domain"
|
||||
value={currentTenant.tenant_domain}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<CustomInput
|
||||
label="Tenant Logo URL"
|
||||
value={currentTenant.tenant_logo_url ?? ""}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 text-sm text-gray-500">
|
||||
No tenant assigned.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-lg border border-gray-200 bg-white p-6"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<CustomInput
|
||||
label="Tenant Name"
|
||||
name="tenant_name"
|
||||
placeholder="Enter tenant name"
|
||||
value={formData.tenant_name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
<CustomInput
|
||||
label="Tenant Domain"
|
||||
name="tenant_domain"
|
||||
placeholder="example.com"
|
||||
value={formData.tenant_domain}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CustomInput
|
||||
label="Tenant Logo URL"
|
||||
name="tenant_logo_url"
|
||||
placeholder="https://"
|
||||
value={formData.tenant_logo_url}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<CustomButton type="submit" variant="primary" disabled={isLoading} loading={isLoading}>
|
||||
Create Tenant
|
||||
</CustomButton>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddTenants;
|
||||
@@ -0,0 +1,599 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Edit2, Eye, Trash2 } from "lucide-react";
|
||||
import {
|
||||
CustomButton,
|
||||
CustomCheckBox,
|
||||
CustomConfirmationModal,
|
||||
CustomInput,
|
||||
CustomModal,
|
||||
CustomTable,
|
||||
CustomStatus,
|
||||
CustomLoader,
|
||||
CustomActionMenu,
|
||||
CustomActionItem,
|
||||
} from "../../../components/custom";
|
||||
import type { ColumnDef } from "../../../components/custom/CustomTable";
|
||||
import type { Tenant, TenantUpdateRequest } from "../TenantsTypes";
|
||||
import { tenantsApi } from "../TenantsApi";
|
||||
import { useAuth } from "../../../context/AuthContext";
|
||||
|
||||
// Local implementation of useDebounce
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
// Local implementation of ProtectedComponent
|
||||
const ProtectedComponent: React.FC<{
|
||||
requiredAccess: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ requiredAccess, children }) => {
|
||||
const { hasAccess } = useAuth();
|
||||
if (!hasAccess(requiredAccess)) {
|
||||
return null;
|
||||
}
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string | null) => {
|
||||
if (!dateString) return "";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return dateString;
|
||||
|
||||
// Check if the input likely contains specific time (ISO with T or explicit time chars)
|
||||
const hasTime = dateString.includes("T") || dateString.includes(":");
|
||||
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
};
|
||||
|
||||
if (hasTime) {
|
||||
options.hour = "2-digit",
|
||||
options.minute = "2-digit",
|
||||
options.hour12 = false;
|
||||
}
|
||||
|
||||
return date.toLocaleString("en-GB", options);
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const AllTenants = () => {
|
||||
const { hasAccess, isLoading: isAuthLoading } = useAuth();
|
||||
const canReadAll = hasAccess("superadmin.tenant.read");
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [selectedTenant, setSelectedTenant] = useState<Tenant | null>(null);
|
||||
const [isViewOpen, setIsViewOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [editForm, setEditForm] = useState({
|
||||
tenant_name: "",
|
||||
tenant_domain: "",
|
||||
tenant_logo_url: "",
|
||||
is_active: true,
|
||||
});
|
||||
const [editError, setEditError] = useState("");
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Pagination state
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [search, setSearch] = useState("");
|
||||
const [totalRows, setTotalRows] = useState(0);
|
||||
const [, setTotalPages] = useState(0);
|
||||
|
||||
// Status filter state
|
||||
const [statusFilter, setStatusFilter] = useState<boolean | null>(null);
|
||||
|
||||
// Ref for the search input to restore focus
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Track previous loading state to detect transition from loading → idle
|
||||
const prevLoadingRef = useRef(isLoading);
|
||||
|
||||
// Debounce search to avoid excessive API calls
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadTenants = async () => {
|
||||
if (isAuthLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
if (canReadAll) {
|
||||
// Use paginated API for server-side pagination
|
||||
const response = await tenantsApi.getPaginated({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
search: debouncedSearch || undefined,
|
||||
is_active: statusFilter,
|
||||
});
|
||||
|
||||
if (isMounted) {
|
||||
setTenants(response.items);
|
||||
setTotalRows(response.total);
|
||||
setTotalPages(response.total_pages);
|
||||
}
|
||||
} else {
|
||||
// For non-superadmin, show only their tenant
|
||||
const data = await tenantsApi.getMine();
|
||||
if (isMounted) {
|
||||
setTenants([data]);
|
||||
setTotalRows(1);
|
||||
setTotalPages(1);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMounted) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to load tenants.";
|
||||
setErrorMessage(message);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadTenants();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [canReadAll, isAuthLoading, page, pageSize, debouncedSearch, statusFilter]);
|
||||
|
||||
// Restore focus to search input after loading completes (only if user was searching)
|
||||
useEffect(() => {
|
||||
if (prevLoadingRef.current && !isLoading && search.trim() !== "") {
|
||||
searchInputRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
prevLoadingRef.current = isLoading;
|
||||
}, [isLoading, search]);
|
||||
|
||||
// Reset to page 1 when search or status filter changes
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch, statusFilter]);
|
||||
|
||||
const openView = useCallback((tenant: Tenant) => {
|
||||
setSelectedTenant(tenant);
|
||||
setIsViewOpen(true);
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback((tenant: Tenant) => {
|
||||
setSelectedTenant(tenant);
|
||||
setEditForm({
|
||||
tenant_name: tenant.tenant_name,
|
||||
tenant_domain: tenant.tenant_domain,
|
||||
tenant_logo_url: tenant.tenant_logo_url ?? "",
|
||||
is_active: tenant.is_active,
|
||||
});
|
||||
setEditError("");
|
||||
setIsEditOpen(true);
|
||||
}, []);
|
||||
|
||||
const openDelete = useCallback((tenant: Tenant) => {
|
||||
setSelectedTenant(tenant);
|
||||
setDeleteError("");
|
||||
setIsDeleteOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeView = useCallback(() => {
|
||||
setIsViewOpen(false);
|
||||
setSelectedTenant(null);
|
||||
}, []);
|
||||
|
||||
const closeEdit = useCallback(() => {
|
||||
setIsEditOpen(false);
|
||||
setSelectedTenant(null);
|
||||
setEditError("");
|
||||
}, []);
|
||||
|
||||
const closeDelete = useCallback(() => {
|
||||
setIsDeleteOpen(false);
|
||||
setSelectedTenant(null);
|
||||
setDeleteError("");
|
||||
}, []);
|
||||
|
||||
const handleEditChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = event.target;
|
||||
setEditForm((prev) => ({ ...prev, [name]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEditForm((prev) => ({ ...prev, is_active: event.target.checked }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleUpdate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedTenant) return;
|
||||
|
||||
setEditError("");
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const payload: TenantUpdateRequest = {
|
||||
tenant_name: editForm.tenant_name.trim(),
|
||||
tenant_domain: editForm.tenant_domain.trim(),
|
||||
tenant_logo_url: editForm.tenant_logo_url.trim() || null,
|
||||
is_active: editForm.is_active,
|
||||
};
|
||||
|
||||
const updatedTenant = await tenantsApi.update(selectedTenant.id, payload);
|
||||
setTenants((prev) =>
|
||||
prev.map((tenant) =>
|
||||
tenant.id === updatedTenant.id ? updatedTenant : tenant
|
||||
)
|
||||
);
|
||||
setIsEditOpen(false);
|
||||
setSelectedTenant(null);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unable to update tenant.";
|
||||
setEditError(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedTenant) return;
|
||||
|
||||
setDeleteError("");
|
||||
setIsDeleting(true);
|
||||
|
||||
try {
|
||||
await tenantsApi.remove(selectedTenant.id);
|
||||
setTenants((prev) =>
|
||||
prev.filter((tenant) => tenant.id !== selectedTenant.id)
|
||||
);
|
||||
setIsDeleteOpen(false);
|
||||
setSelectedTenant(null);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unable to delete tenant.";
|
||||
setDeleteError(message);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo<Array<ColumnDef<Tenant>>>(
|
||||
() => [
|
||||
{ key: "tenant_name", header: "Tenant Name" },
|
||||
{ key: "tenant_domain", header: "Domain" },
|
||||
{
|
||||
key: "tenant_logo_url",
|
||||
header: "Logo",
|
||||
searchable: false,
|
||||
render: (row) =>
|
||||
row.tenant_logo_url ? (
|
||||
<img
|
||||
src={row.tenant_logo_url}
|
||||
alt={row.tenant_name}
|
||||
className="h-8 w-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-(--text-secondary)"></span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
header: "Status",
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<CustomStatus status={row.is_active ? "Active" : "Inactive"} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
header: "Created",
|
||||
render: (row) => (
|
||||
<div className="text-sm font-medium text-(--text-primary)">
|
||||
{formatDate(row.created_at)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updated_at",
|
||||
header: "Updated",
|
||||
render: (row) => (
|
||||
<div className="text-sm font-medium text-(--text-primary)">
|
||||
{formatDate(row.updated_at)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "id",
|
||||
header: "Action",
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<CustomActionMenu>
|
||||
<ProtectedComponent requiredAccess="superadmin.tenant.read">
|
||||
<CustomActionItem onClick={() => openView(row)}>
|
||||
<Eye size={16} className="mr-2" /> View Details
|
||||
</CustomActionItem>
|
||||
</ProtectedComponent>
|
||||
|
||||
<ProtectedComponent requiredAccess="superadmin.tenant.update">
|
||||
<CustomActionItem onClick={() => openEdit(row)}>
|
||||
<Edit2 size={16} className="mr-2" /> Edit Tenant
|
||||
</CustomActionItem>
|
||||
</ProtectedComponent>
|
||||
|
||||
<ProtectedComponent requiredAccess="superadmin.tenant.delete">
|
||||
<CustomActionItem onClick={() => openDelete(row)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
|
||||
<Trash2 size={16} className="mr-2" /> Delete Tenant
|
||||
</CustomActionItem>
|
||||
</ProtectedComponent>
|
||||
</CustomActionMenu>
|
||||
),
|
||||
},
|
||||
],
|
||||
[openDelete, openEdit, openView]
|
||||
);
|
||||
|
||||
// Status filter control
|
||||
const filterControls = canReadAll ? (
|
||||
<select
|
||||
value={statusFilter === null ? "all" : statusFilter ? "active" : "inactive"}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setStatusFilter(
|
||||
value === "all" ? null : value === "active" ? true : false
|
||||
);
|
||||
}}
|
||||
className="rounded-md border border-(--card-border) bg-(--card-bg) text-(--text-primary) text-sm py-1.5 px-2 focus:outline-none focus:ring focus:ring-blue-600"
|
||||
>
|
||||
<option value="all" className="bg-(--card-bg) text-(--text-primary)">All Statuses</option>
|
||||
<option value="active" className="bg-(--card-bg) text-(--text-primary)">Active</option>
|
||||
<option value="inactive" className="bg-(--card-bg) text-(--text-primary)">Inactive</option>
|
||||
</select>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">Tenants</h1>
|
||||
{/* <p className="text-sm text-[var(--text-secondary)]">
|
||||
{totalRows} Tenant{totalRows === 1 ? "" : "s"} in total
|
||||
</p> */}
|
||||
</div>
|
||||
<ProtectedComponent requiredAccess="superadmin.tenant.create">
|
||||
<Link to="/tenants/add">
|
||||
<CustomButton variant="primary">+ Add Tenant</CustomButton>
|
||||
</Link>
|
||||
</ProtectedComponent>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-6">
|
||||
<CustomLoader />
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : (
|
||||
<CustomTable
|
||||
data={tenants}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.id}
|
||||
manualPagination={canReadAll}
|
||||
manualFiltering={canReadAll}
|
||||
totalRows={totalRows}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
onSearchChange={setSearch}
|
||||
searchInputRef={searchInputRef}
|
||||
filterControls={filterControls}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CustomModal
|
||||
isOpen={isViewOpen}
|
||||
onClose={closeView}
|
||||
title="Tenant Details"
|
||||
size="lg"
|
||||
footer={
|
||||
<CustomButton type="button" variant="outlined" onClick={closeView}>
|
||||
Close
|
||||
</CustomButton>
|
||||
}
|
||||
>
|
||||
{selectedTenant ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Name
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{selectedTenant.tenant_name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Domain
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{selectedTenant.tenant_domain}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Logo
|
||||
</p>
|
||||
{selectedTenant.tenant_logo_url ? (
|
||||
<img
|
||||
src={selectedTenant.tenant_logo_url}
|
||||
alt={selectedTenant.tenant_name}
|
||||
className="h-12 w-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--text-secondary)]"></p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Status
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{selectedTenant.is_active ? "Active" : "Inactive"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Created
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{formatDate(selectedTenant.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Updated
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{formatDate(selectedTenant.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--text-secondary)]">No tenant selected.</p>
|
||||
)}
|
||||
</CustomModal>
|
||||
|
||||
<CustomModal
|
||||
isOpen={isEditOpen}
|
||||
onClose={closeEdit}
|
||||
title="Edit Tenant"
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<CustomButton
|
||||
type="button"
|
||||
variant="outlined"
|
||||
onClick={closeEdit}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
type="submit"
|
||||
form="edit-tenant-form"
|
||||
variant="primary"
|
||||
loading={isSaving}
|
||||
>
|
||||
Save Changes
|
||||
</CustomButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="edit-tenant-form"
|
||||
onSubmit={handleUpdate}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<CustomInput
|
||||
label="Tenant Name"
|
||||
name="tenant_name"
|
||||
placeholder="Enter tenant name"
|
||||
value={editForm.tenant_name}
|
||||
onChange={handleEditChange}
|
||||
required
|
||||
/>
|
||||
<CustomInput
|
||||
label="Tenant Domain"
|
||||
name="tenant_domain"
|
||||
placeholder="example.com"
|
||||
value={editForm.tenant_domain}
|
||||
onChange={handleEditChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CustomInput
|
||||
label="Tenant Logo URL"
|
||||
name="tenant_logo_url"
|
||||
placeholder="https://"
|
||||
value={editForm.tenant_logo_url}
|
||||
onChange={handleEditChange}
|
||||
/>
|
||||
|
||||
<div className="flex mt-6 items-center">
|
||||
{/* Checkbox component not found in standard custom, using explicit input or different component if needed,
|
||||
but keeping CustomSwitch or similar logic is safer. User had CustomCheckBox.
|
||||
I'll assume I need to replace it with simple input or use CustomSwitch if I saw it.
|
||||
I saw CustomSwitch in my files but not necessarily in the user's previous code unless I missed it.
|
||||
Actually, CustomCheckBox was imported from "Custom". I have "CustomSwitch".
|
||||
Let's use CustomSwitch for "is_active" as it's cleaner. */}
|
||||
<CustomCheckBox
|
||||
label="Active"
|
||||
name="is_active"
|
||||
checked={editForm.is_active}
|
||||
onChange={handleStatusChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
|
||||
{editError}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</CustomModal>
|
||||
|
||||
<CustomConfirmationModal
|
||||
isOpen={isDeleteOpen}
|
||||
onClose={closeDelete}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete tenant?"
|
||||
description={
|
||||
deleteError || "This tenant will be permanently removed."
|
||||
}
|
||||
confirmText="Delete Tenant"
|
||||
variant="danger"
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllTenants;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import AllTenants from "./components/AllTenants";
|
||||
import AddTenants from "./components/AddTenants";
|
||||
|
||||
const TenantsRoutes: React.FC = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<AllTenants />} />
|
||||
<Route path="add" element={<AddTenants />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
export default TenantsRoutes;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import { Plus, Edit2, Trash2 } from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
import {CustomButton} from "../../../components/custom/CustomButton";
|
||||
import {CustomButton} from "../../../components/custom";
|
||||
import DataTable, { type ColumnDef } from "../../../components/custom/CustomTable";
|
||||
import { CustomActionMenu, CustomActionItem, CustomStatus } from "../../../components/custom";
|
||||
import { Loader } from "../../../components/custom/CustomLoader";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useForm } from "react-hook-form";
|
||||
import { Check } from "lucide-react";
|
||||
import CustomModal from "../../../components/custom/CustomModal";
|
||||
import CustomInput from "../../../components/custom/CustomInput";
|
||||
import { CustomButton } from "../../../components/custom/CustomButton";
|
||||
import {CustomButton} from "../../../components/custom";
|
||||
import type { ColorPalette } from "../ThemeTypes";
|
||||
|
||||
interface PaletteFormProps {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
|
||||
interface ProtectedComponentProps {
|
||||
children: ReactNode;
|
||||
requiredAccess?: string;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
export const ProtectedComponent: React.FC<ProtectedComponentProps> = ({
|
||||
children,
|
||||
requiredAccess,
|
||||
fallback = null,
|
||||
}) => {
|
||||
const { hasAccess, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (requiredAccess && !hasAccess(requiredAccess)) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import { CustomLoader } from "../custom";
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
requiredAccess?: string;
|
||||
}
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
children,
|
||||
requiredAccess,
|
||||
}) => {
|
||||
const { isAuthenticated, isLoading, hasAccess } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return <CustomLoader />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/signin" replace />;
|
||||
}
|
||||
|
||||
if (requiredAccess && !hasAccess(requiredAccess)) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface StateData {
|
||||
name: string;
|
||||
state_code?: string;
|
||||
}
|
||||
|
||||
export const useCountriesNow = () => {
|
||||
const [statesLoading, setStatesLoading] = useState(false);
|
||||
const [citiesLoading, setCitiesLoading] = useState(false);
|
||||
const [states, setStates] = useState<string[]>([]);
|
||||
const [cities, setCities] = useState<string[]>([]);
|
||||
|
||||
const fetchStates = useCallback(async (countryName: string): Promise<string[]> => {
|
||||
if (!countryName) {
|
||||
setStates([]);
|
||||
return [];
|
||||
}
|
||||
|
||||
setStatesLoading(true);
|
||||
try {
|
||||
const response = await fetch('https://countriesnow.space/api/v0.1/countries/states', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ country: countryName }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.data?.states) {
|
||||
const stateNames = data.data.states.map((s: StateData) => s.name);
|
||||
setStates(stateNames);
|
||||
return stateNames;
|
||||
}
|
||||
}
|
||||
setStates([]);
|
||||
return [];
|
||||
} catch (error) {
|
||||
setStates([]);
|
||||
return [];
|
||||
} finally {
|
||||
setStatesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchCities = useCallback(async (countryName: string, stateName: string): Promise<string[]> => {
|
||||
if (!countryName || !stateName) {
|
||||
setCities([]);
|
||||
return [];
|
||||
}
|
||||
|
||||
setCitiesLoading(true);
|
||||
try {
|
||||
const response = await fetch('https://countriesnow.space/api/v0.1/countries/state/cities', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ country: countryName, state: stateName }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.data) {
|
||||
setCities(data.data);
|
||||
return data.data;
|
||||
}
|
||||
}
|
||||
setCities([]);
|
||||
return [];
|
||||
} catch (error) {
|
||||
setCities([]);
|
||||
return [];
|
||||
} finally {
|
||||
setCitiesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearStates = useCallback(() => setStates([]), []);
|
||||
const clearCities = useCallback(() => setCities([]), []);
|
||||
|
||||
return {
|
||||
fetchStates,
|
||||
fetchCities,
|
||||
clearStates,
|
||||
clearCities,
|
||||
states,
|
||||
cities,
|
||||
statesLoading,
|
||||
citiesLoading,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number = 500): T {
|
||||
const [debounced, setDebounced] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface PostalCodeResult {
|
||||
city: string;
|
||||
state: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export const usePostalCodeLookup = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const lookupPostalCode = async (
|
||||
postalCode: string,
|
||||
countryCode: string
|
||||
): Promise<PostalCodeResult> => {
|
||||
if (!postalCode || postalCode.length < 3) {
|
||||
return { city: '', state: '', success: false };
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (countryCode.toUpperCase() === 'IN' && postalCode.length === 6) {
|
||||
const response = await fetch(
|
||||
`https://api.postalpincode.in/pincode/${postalCode}`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (data[0]?.Status === 'Success' && data[0]?.PostOffice?.length > 0) {
|
||||
const { District, State } = data[0].PostOffice[0];
|
||||
return {
|
||||
city: District,
|
||||
state: State,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const zipResponse = await fetch(
|
||||
`https://api.zippopotam.us/${countryCode.toLowerCase()}/${postalCode}`
|
||||
);
|
||||
|
||||
if (zipResponse.ok) {
|
||||
const data = await zipResponse.json();
|
||||
|
||||
if (data && data.places && data.places.length > 0) {
|
||||
let selectedPlace = data.places[0];
|
||||
|
||||
if (data.places.length > 1) {
|
||||
const cityPlace = data.places.find((place: any) => {
|
||||
const placeName = place['place name'] || '';
|
||||
return placeName.split(' ').length <= 2;
|
||||
});
|
||||
if (cityPlace) {
|
||||
selectedPlace = cityPlace;
|
||||
}
|
||||
}
|
||||
|
||||
const city = selectedPlace['place name'] || '';
|
||||
const state =
|
||||
selectedPlace['state'] || selectedPlace['state abbreviation'] || '';
|
||||
|
||||
return {
|
||||
city,
|
||||
state,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { city: '', state: '', success: false };
|
||||
|
||||
} catch (error) {
|
||||
return { city: '', state: '', success: false };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
lookupPostalCode,
|
||||
loading,
|
||||
};
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useSidebar } from "../../context/SidebarContext";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import { authApi } from "../../application/Authentication/AuthApi";
|
||||
import { authApi } from "../../application/authentication/AuthApi";
|
||||
import { clearAuthCookies } from "../../lib/authCookies";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -76,43 +76,43 @@ const AppHeader: React.FC = () => {
|
||||
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="currentColor" strokeOpacity="0.1" strokeWidth="0.5" className="text-[var(--header-text)]" />
|
||||
</pattern>
|
||||
<rect width="320" height="40" fill="url(#grid)" />
|
||||
|
||||
|
||||
{/* Multiple route paths */}
|
||||
<path d="M0 30 C40 30, 60 10, 100 10 S160 30, 200 20 S260 10, 320 15" stroke="currentColor" strokeOpacity="0.2" strokeWidth="2" fill="none" className="text-[var(--header-text)]" />
|
||||
<path d="M0 20 C50 15, 80 25, 120 20 S180 10, 220 15 S280 25, 320 20" stroke="currentColor" strokeOpacity="0.3" strokeWidth="1.5" fill="none" className="text-[var(--header-text)]" />
|
||||
<path d="M0 10 C30 15, 70 5, 110 12 S170 20, 210 12 S270 8, 320 12" stroke="currentColor" strokeOpacity="0.4" strokeWidth="1" strokeDasharray="4 2" fill="none" className="text-[var(--header-text)]" />
|
||||
|
||||
|
||||
{/* Hub nodes with glow effect */}
|
||||
<circle cx="40" cy="25" r="6" fill="currentColor" fillOpacity="0.1" stroke="currentColor" strokeOpacity="0.4" strokeWidth="1.5" className="text-[var(--header-text)]" />
|
||||
<circle cx="40" cy="25" r="3" fill="currentColor" className="text-[var(--primary)]" />
|
||||
|
||||
|
||||
<circle cx="120" cy="15" r="8" fill="currentColor" fillOpacity="0.1" stroke="currentColor" strokeOpacity="0.5" strokeWidth="2" className="text-[var(--header-text)]" />
|
||||
<circle cx="120" cy="15" r="4" fill="currentColor" className="text-[var(--primary)]" />
|
||||
|
||||
|
||||
<circle cx="200" cy="18" r="6" fill="currentColor" fillOpacity="0.1" stroke="currentColor" strokeOpacity="0.4" strokeWidth="1.5" className="text-[var(--header-text)]" />
|
||||
<circle cx="200" cy="18" r="3" fill="currentColor" className="text-[var(--primary)]" />
|
||||
|
||||
|
||||
<circle cx="280" cy="16" r="5" fill="currentColor" fillOpacity="0.1" stroke="currentColor" strokeOpacity="0.3" strokeWidth="1" className="text-[var(--header-text)]" />
|
||||
<circle cx="280" cy="16" r="2.5" fill="currentColor" className="text-[var(--primary)]" />
|
||||
|
||||
|
||||
{/* Package icons */}
|
||||
<g transform="translate(70, 22)">
|
||||
<rect width="10" height="8" rx="1" fill="currentColor" fillOpacity="0.2" stroke="currentColor" strokeWidth="0.8" className="text-[var(--header-text)]" />
|
||||
<line x1="0" y1="3" x2="10" y2="3" stroke="currentColor" strokeWidth="0.5" className="text-[var(--header-text)]" />
|
||||
<line x1="5" y1="3" x2="5" y2="8" stroke="currentColor" strokeWidth="0.5" className="text-[var(--header-text)]" />
|
||||
</g>
|
||||
|
||||
|
||||
<g transform="translate(160, 8)">
|
||||
<rect width="10" height="8" rx="1" fill="currentColor" fillOpacity="0.2" stroke="currentColor" strokeWidth="0.8" className="text-(--header-text)" />
|
||||
<line x1="0" y1="3" x2="10" y2="3" stroke="currentColor" strokeWidth="0.5" className="text-(--header-text)" />
|
||||
<line x1="5" y1="3" x2="5" y2="8" stroke="currentColor" strokeWidth="0.5" className="text-(--header-text)" />
|
||||
<rect width="10" height="8" rx="1" fill="currentColor" fillOpacity="0.2" stroke="currentColor" strokeWidth="0.8" className="text-(--header-text)" />
|
||||
<line x1="0" y1="3" x2="10" y2="3" stroke="currentColor" strokeWidth="0.5" className="text-(--header-text)" />
|
||||
<line x1="5" y1="3" x2="5" y2="8" stroke="currentColor" strokeWidth="0.5" className="text-(--header-text)" />
|
||||
</g>
|
||||
|
||||
|
||||
<g transform="translate(240, 20)">
|
||||
<rect width="8" height="6" rx="1" fill="currentColor" fillOpacity="0.2" stroke="currentColor" strokeWidth="0.8" className="text-(--header-text)" />
|
||||
<line x1="0" y1="2" x2="8" y2="2" stroke="currentColor" strokeWidth="0.5" className="text-(--header-text)" />
|
||||
</g>
|
||||
|
||||
|
||||
{/* Connecting dots */}
|
||||
<circle cx="20" cy="22" r="1.5" fill="currentColor" fillOpacity="0.5" className="text-(--header-text)" />
|
||||
<circle cx="90" cy="12" r="1.5" fill="currentColor" fillOpacity="0.5" className="text-(--header-text)" />
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { useSidebar } from "../../context/SidebarContext";
|
||||
import { usePermission } from "../../lib/usePermission";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import { authApi } from "../../application/Authentication/AuthApi";
|
||||
import { authApi } from "../../application/authentication/AuthApi";
|
||||
import { clearAuthCookies } from "../../lib/authCookies";
|
||||
|
||||
|
||||
@@ -66,11 +66,9 @@ const navItems: NavItem[] = [
|
||||
{
|
||||
icon: <Palette size={22} />,
|
||||
name: "Themes",
|
||||
path: "/themes",
|
||||
path: "/theme",
|
||||
access: "superadmin.palette.read",
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
icon: <Settings size={22} />,
|
||||
name: "Settings",
|
||||
|
||||
+10
-10
@@ -1,10 +1,10 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { authApi } from "../application/Authentication/AuthApi";
|
||||
import { authApi } from "../application/authentication/AuthApi";
|
||||
import type {
|
||||
AuthUser,
|
||||
SigninRequest,
|
||||
} from "../application/Authentication/AuthTypes";
|
||||
} from "../application/authentication/AuthTypes";
|
||||
import {
|
||||
setAuthCookies,
|
||||
getAccessToken,
|
||||
@@ -43,7 +43,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({
|
||||
try {
|
||||
const userData = await authApi.me();
|
||||
setUser(userData);
|
||||
|
||||
|
||||
if (userData.preferred_language) {
|
||||
i18n.changeLanguage(userData.preferred_language);
|
||||
}
|
||||
@@ -69,11 +69,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
clearAuthCookies();
|
||||
setUser(null);
|
||||
window.location.href = "/signin";
|
||||
};
|
||||
const logout = async () => {
|
||||
clearAuthCookies();
|
||||
setUser(null);
|
||||
window.location.href = "/signin";
|
||||
};
|
||||
|
||||
|
||||
const hasAccess = (accessCode: string): boolean => {
|
||||
@@ -85,7 +85,7 @@ const logout = async () => {
|
||||
try {
|
||||
const userData = await authApi.me();
|
||||
setUser(userData);
|
||||
|
||||
|
||||
if (userData.preferred_language) {
|
||||
i18n.changeLanguage(userData.preferred_language);
|
||||
}
|
||||
@@ -95,7 +95,7 @@ const logout = async () => {
|
||||
|
||||
const updateLanguage = async (language: string) => {
|
||||
if (!user) throw new Error('No user logged in');
|
||||
|
||||
|
||||
try {
|
||||
const updatedUser = await authApi.updateLanguage(user.id, language);
|
||||
setUser(updatedUser);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {type TokenResponse } from "../application/Authentication/AuthTypes";
|
||||
import { type TokenResponse } from "../application/authentication/AuthTypes";
|
||||
|
||||
export const AUTH_COOKIE_KEYS = {
|
||||
access: "access_token",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Route, Routes, Navigate } from "react-router-dom";
|
||||
|
||||
import SignInPage from "../application/Authentication/SignInPage";
|
||||
import SignUpPage from "../application/Authentication/SignUpPage";
|
||||
import ResetPasswordPage from "../application/Authentication/ResetPasswordPage";
|
||||
import SignInPage from "../application/authentication/SignInPage";
|
||||
import SignUpPage from "../application/authentication/SignUpPage";
|
||||
import ResetPasswordPage from "../application/authentication/ResetPasswordPage";
|
||||
import Roles from "../application/roles";
|
||||
import Dashboard from "../application/dashboard/Dashboard";
|
||||
import ProfilePage from "../application/profile/ProfilePage";
|
||||
import Tenants from "../application/tenants";
|
||||
import ProtectedRoutes from "./ProtectedRoutes";
|
||||
|
||||
import Theme from "../application/theme";
|
||||
const AppRoutes = () => {
|
||||
return (
|
||||
<Routes>
|
||||
@@ -19,6 +21,9 @@ const AppRoutes = () => {
|
||||
<Route element={<ProtectedRoutes />}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/tenants/*" element={<Tenants />} />
|
||||
<Route path="/roles/*" element={<Roles />} />
|
||||
<Route path="/theme/*" element={<Theme />} />
|
||||
{/* Redirect root to dashboard if logged in (ProtectedRoutes handles auth check usually, or we redirect to signin if not) */}
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Route>
|
||||
|
||||
Reference in New Issue
Block a user