mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 13:28:11 +00:00
user management ui
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { isComplaintFaydaState } from "@/shared/utils/faydaOidc";
|
||||
import ComplaintCallbackPage from "@/complaints/pages/ComplaintCallbackPage";
|
||||
import ExternalPortalCallback from "@/external-portal/components/Registration/ExternalPortalCallBack";
|
||||
|
||||
/**
|
||||
* Single FAYDA OIDC callback entry point.
|
||||
* Fayda only allows whitelisted redirect URIs (e.g. /callback) — route
|
||||
* internally based on the `state` param sent during authorization.
|
||||
*/
|
||||
export default function FaydaCallbackDispatcher() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const state = searchParams.get("state");
|
||||
|
||||
if (isComplaintFaydaState(state)) {
|
||||
return <ComplaintCallbackPage />;
|
||||
}
|
||||
|
||||
return <ExternalPortalCallback />;
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthUser } from "../hooks/useAuthUser";
|
||||
import { useErrorHandler } from "../hooks/useErrorHandler";
|
||||
import { Lock, Mail, Eye, EyeOff, RefreshCw, ArrowLeft } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const SetPasswordPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
const [searchParams] = useSearchParams();
|
||||
const {
|
||||
setPassword,
|
||||
isSettingPassword,
|
||||
setPasswordError,
|
||||
setPasswordSuccess,
|
||||
resendVerificationCode,
|
||||
isResendingCode,
|
||||
resendError,
|
||||
resendSuccess,
|
||||
} = useAuthUser();
|
||||
|
||||
const email = searchParams.get("email");
|
||||
const verificationCode = searchParams.get("verificationCode") || "";
|
||||
const phoneNumber = searchParams.get("phoneNumber");
|
||||
const userId = searchParams.get("userId");
|
||||
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [resendAttempted, setResendAttempted] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Password validation rules
|
||||
const getPasswordValidation = (password: string) => {
|
||||
return {
|
||||
minLength: password.length >= 8,
|
||||
hasLowercase: /[a-z]/.test(password),
|
||||
hasUppercase: /[A-Z]/.test(password),
|
||||
hasNumber: /\d/.test(password),
|
||||
hasSymbol: /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password),
|
||||
};
|
||||
};
|
||||
|
||||
const passwordValidation = useMemo(
|
||||
() => getPasswordValidation(newPassword),
|
||||
[newPassword]
|
||||
);
|
||||
const isPasswordStrong = Object.values(passwordValidation).every(Boolean);
|
||||
const isPasswordMatch = confirmPassword.length > 0 && newPassword === confirmPassword;
|
||||
const canSubmit = Boolean(newPassword && confirmPassword && isPasswordStrong && isPasswordMatch);
|
||||
|
||||
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const password = e.target.value;
|
||||
setNewPassword(password);
|
||||
if (error) setError("");
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitAttempted(true);
|
||||
setError("");
|
||||
|
||||
if (!newPassword || !confirmPassword) {
|
||||
setError("Please fill in both fields");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
if (!isPasswordStrong) {
|
||||
const missingRequirements = [];
|
||||
if (!passwordValidation.minLength) missingRequirements.push("at least 8 characters");
|
||||
if (!passwordValidation.hasLowercase) missingRequirements.push("one lowercase letter");
|
||||
if (!passwordValidation.hasUppercase) missingRequirements.push("one uppercase letter");
|
||||
if (!passwordValidation.hasNumber) missingRequirements.push("one number");
|
||||
if (!passwordValidation.hasSymbol) missingRequirements.push("one symbol (!@#$%^&*...)");
|
||||
|
||||
setError(`Password must contain: ${missingRequirements.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPasswordMatch) {
|
||||
setError("Passwords don't match");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare payload - include userId and email when available
|
||||
const payload: any = {
|
||||
verificationCode,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
};
|
||||
|
||||
if (userId) {
|
||||
payload.userId = userId;
|
||||
}
|
||||
|
||||
if (email) {
|
||||
payload.email = email;
|
||||
} else if (phoneNumber) {
|
||||
// Apply the same phone number formatting logic
|
||||
let processedPhoneNumber = phoneNumber;
|
||||
if (processedPhoneNumber.startsWith("0")) {
|
||||
processedPhoneNumber = "+251" + processedPhoneNumber.slice(1);
|
||||
}
|
||||
payload.phoneNumber = processedPhoneNumber;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setPassword(payload);
|
||||
};
|
||||
|
||||
const handleResendCode = () => {
|
||||
if (!phoneNumber && !email) {
|
||||
toast.error("User ID is missing. Cannot resend verification code.");
|
||||
return;
|
||||
}
|
||||
setResendAttempted(true);
|
||||
resendVerificationCode({ phoneNumber: phoneNumber || "", email: email || undefined });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (setPasswordSuccess) {
|
||||
toast.success("Password updated successfully! You can now login");
|
||||
setIsSubmitting(false);
|
||||
navigate("/login");
|
||||
}
|
||||
}, [setPasswordSuccess, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!submitAttempted) return;
|
||||
if (setPasswordError?.message) {
|
||||
setIsSubmitting(false);
|
||||
// Use handleError hook to process backend errors
|
||||
handleError(setPasswordError);
|
||||
}
|
||||
}, [setPasswordError?.message, handleError, submitAttempted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resendSuccess && resendAttempted) {
|
||||
toast.success("Verification code resent successfully!");
|
||||
setResendAttempted(false);
|
||||
}
|
||||
if (resendError && resendAttempted) {
|
||||
// Use handleError hook to process backend errors
|
||||
handleError(resendError);
|
||||
setResendAttempted(false);
|
||||
}
|
||||
}, [resendSuccess, resendError, resendAttempted, handleError]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center bg-gray-100 p-4"
|
||||
style={{ height: "100vh" }}
|
||||
>
|
||||
<div className="w-full max-w-md bg-white rounded-xl shadow-lg p-8">
|
||||
<div className="mb-8 text-center">
|
||||
<img
|
||||
src="/assets/smart-office-logo.svg"
|
||||
alt="Smart Office Logo"
|
||||
className="h-10 mb-6 mx-auto"
|
||||
/>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Set Password
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Set a new password for your account
|
||||
</p>
|
||||
{email && (
|
||||
<p className="text-xs text-gray-400 mt-1">Email: {email}</p>
|
||||
)}
|
||||
{phoneNumber && (
|
||||
<p className="text-xs text-gray-400 mt-1">Phone: {phoneNumber}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="New Password"
|
||||
className="h-10 rounded-md border px-4 text-sm ps-10 text-black placeholder:text-gray-500"
|
||||
value={newPassword}
|
||||
onChange={handlePasswordChange}
|
||||
required
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password strength indicator */}
|
||||
{newPassword && (
|
||||
<div className="space-y-2 p-3 bg-gray-50 rounded-md border border-gray-200">
|
||||
<p className="text-xs font-medium text-gray-700">Password Requirements:</p>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-4 h-4 rounded-full flex items-center justify-center text-xs ${passwordValidation.minLength ? 'bg-primary-500' : 'bg-gray-300'}`}>
|
||||
{passwordValidation.minLength && <span className="text-white">✓</span>}
|
||||
</div>
|
||||
<span className={`text-xs ${passwordValidation.minLength ? 'text-primary-700' : 'text-gray-600'}`}>
|
||||
At least 8 characters
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-4 h-4 rounded-full flex items-center justify-center text-xs ${passwordValidation.hasUppercase ? 'bg-primary-500' : 'bg-gray-300'}`}>
|
||||
{passwordValidation.hasUppercase && <span className="text-white">✓</span>}
|
||||
</div>
|
||||
<span className={`text-xs ${passwordValidation.hasUppercase ? 'text-primary-700' : 'text-gray-600'}`}>
|
||||
One uppercase letter (A-Z)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-4 h-4 rounded-full flex items-center justify-center text-xs ${passwordValidation.hasLowercase ? 'bg-primary-500' : 'bg-gray-300'}`}>
|
||||
{passwordValidation.hasLowercase && <span className="text-white">✓</span>}
|
||||
</div>
|
||||
<span className={`text-xs ${passwordValidation.hasLowercase ? 'text-primary-700' : 'text-gray-600'}`}>
|
||||
One lowercase letter (a-z)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-4 h-4 rounded-full flex items-center justify-center text-xs ${passwordValidation.hasNumber ? 'bg-primary-500' : 'bg-gray-300'}`}>
|
||||
{passwordValidation.hasNumber && <span className="text-white">✓</span>}
|
||||
</div>
|
||||
<span className={`text-xs ${passwordValidation.hasNumber ? 'text-primary-700' : 'text-gray-600'}`}>
|
||||
One number (0-9)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-4 h-4 rounded-full flex items-center justify-center text-xs ${passwordValidation.hasSymbol ? 'bg-primary-500' : 'bg-gray-300'}`}>
|
||||
{passwordValidation.hasSymbol && <span className="text-white">✓</span>}
|
||||
</div>
|
||||
<span className={`text-xs ${passwordValidation.hasSymbol ? 'text-primary-700' : 'text-gray-600'}`}>
|
||||
One symbol (!@#$%^&*...)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<Input
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="Confirm Password"
|
||||
className="h-10 rounded-md border px-4 text-sm ps-10 text-black placeholder:text-gray-500"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => {
|
||||
setConfirmPassword(e.target.value);
|
||||
if (error) setError("");
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-10 bg-primary hover:bg-primary-300 text-white text-sm"
|
||||
disabled={isSettingPassword || isSubmitting || !canSubmit}
|
||||
>
|
||||
{isSettingPassword ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<RefreshCw className="animate-spin h-5 w-5 mr-2" />
|
||||
Setting Password...
|
||||
</span>
|
||||
) : (
|
||||
"Set Password"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-10 text-sm"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Login
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResendCode}
|
||||
disabled={isResendingCode || !email || !phoneNumber || isSubmitting}
|
||||
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium flex items-center justify-center space-x-1 mx-auto"
|
||||
>
|
||||
{isResendingCode ? (
|
||||
<>
|
||||
<RefreshCw className="animate-spin h-4 w-4 text-indigo-600 mr-2" />
|
||||
<span>Sending...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
<span>Resend Verification Code</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetPasswordPage;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
interface SmartOfficeLoaderProps {
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SmartOfficeLoader({
|
||||
label = "Loading",
|
||||
className,
|
||||
}: SmartOfficeLoaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-screen flex flex-col items-center justify-center gap-5 bg-gradient-to-br from-primary-50 via-white to-primary-50 dark:from-slate-950 dark:via-slate-900 dark:to-primary-950/40",
|
||||
className
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={label}
|
||||
>
|
||||
<div className="relative h-20 w-20">
|
||||
<span className="absolute inset-0 rounded-full border-4 border-primary-100 dark:border-primary-950/80" />
|
||||
<span className="absolute inset-0 rounded-full border-4 border-transparent border-t-primary-600 border-r-primary-500 dark:border-t-primary-400 dark:border-r-primary-500 animate-spin" />
|
||||
<span
|
||||
className="absolute inset-3 rounded-full border-4 border-transparent border-b-primary-500 border-l-primary-400 dark:border-b-primary-300 dark:border-l-primary-400 animate-spin"
|
||||
style={{ animationDirection: "reverse", animationDuration: "1.2s" }}
|
||||
/>
|
||||
<span className="absolute inset-[1.65rem] rounded-full bg-primary-500/85 dark:bg-primary-400/90 shadow-[0_0_24px_rgba(34,197,94,0.45)] dark:shadow-[0_0_30px_rgba(16,185,129,0.7)] animate-pulse" />
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-semibold tracking-wide text-primary-700 dark:text-primary-300">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
|
||||
function ChangePassword() {
|
||||
return (
|
||||
<div>ChangePassword</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChangePassword
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
|
||||
function Profile() {
|
||||
return (
|
||||
<div>Profile</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Profile
|
||||
@@ -0,0 +1,282 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/shared/common/ui/card";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/common/ui/avatar";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
Clock,
|
||||
User,
|
||||
FileText,
|
||||
Settings,
|
||||
Shield,
|
||||
Lock,
|
||||
Upload,
|
||||
Download,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
interface ActivityCardProps {
|
||||
id: string;
|
||||
action: string;
|
||||
description: string;
|
||||
performedBy: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
};
|
||||
timestamp: string;
|
||||
module: string;
|
||||
resourceId?: string;
|
||||
resourceName?: string;
|
||||
status: "success" | "failure" | "pending" | "warning";
|
||||
details?: string;
|
||||
ipAddress?: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
const getModuleIcon = (module: string) => {
|
||||
switch ((module || "").toLowerCase()) {
|
||||
case "authentication":
|
||||
return <Shield className="h-4 w-4" />;
|
||||
case "documents":
|
||||
case "files":
|
||||
return <FileText className="h-4 w-4" />;
|
||||
case "settings":
|
||||
return <Settings className="h-4 w-4" />;
|
||||
case "users":
|
||||
return <User className="h-4 w-4" />;
|
||||
default:
|
||||
return <FileText className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getActionIcon = (action: string) => {
|
||||
switch ((action || "").toLowerCase()) {
|
||||
case "upload":
|
||||
return <Upload className="h-4 w-4" />;
|
||||
case "download":
|
||||
return <Download className="h-4 w-4" />;
|
||||
case "edit":
|
||||
case "update":
|
||||
return <Edit className="h-4 w-4" />;
|
||||
case "delete":
|
||||
return <Trash2 className="h-4 w-4" />;
|
||||
case "view":
|
||||
return <Eye className="h-4 w-4" />;
|
||||
case "login":
|
||||
return <Lock className="h-4 w-4" />;
|
||||
default:
|
||||
return <FileText className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return "bg-primary-100 dark:bg-primary-900/40 text-primary-800 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-900/40 border-primary-300 dark:border-primary-700";
|
||||
case "failure":
|
||||
return "bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/40 border-red-300 dark:border-red-700";
|
||||
case "warning":
|
||||
return "bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300 hover:bg-yellow-100 dark:hover:bg-yellow-900/40 border-yellow-300 dark:border-yellow-700";
|
||||
case "pending":
|
||||
return "bg-primary/15 dark:bg-primary/25 text-primary-800 dark:text-primary-300 hover:bg-primary/15 dark:hover:bg-primary/25 border-primary/40 dark:border-primary/60";
|
||||
default:
|
||||
return "bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border-gray-300 dark:border-gray-600";
|
||||
}
|
||||
};
|
||||
|
||||
export function ActivityCard({ activity }: { activity: ActivityCardProps }) {
|
||||
const { t } = useTranslation();
|
||||
const surface = "bg-white dark:bg-gray-900";
|
||||
const border = "border-gray-200 dark:border-gray-800";
|
||||
const borderLight = "border-gray-100 dark:border-gray-800";
|
||||
const textStrong = "text-gray-900 dark:text-gray-100";
|
||||
const textMuted = "text-gray-600 dark:text-gray-300";
|
||||
const textSubtle = "text-gray-500 dark:text-gray-400";
|
||||
const textSubtler = "text-gray-400 dark:text-gray-500";
|
||||
const iconBg = "bg-gray-100 dark:bg-gray-800";
|
||||
const whiteBg = "bg-white dark:bg-gray-900";
|
||||
|
||||
const formattedDate = format(new Date(activity.timestamp), "MMM d, yyyy");
|
||||
const formattedTime = format(new Date(activity.timestamp), "HH:mm:ss");
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"border transition-shadow duration-200 hover:shadow-md",
|
||||
border,
|
||||
surface,
|
||||
)}>
|
||||
<CardContent className="p-4 sm:p-5">
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<div className="relative shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
"h-10 w-10 sm:h-11 sm:w-11 rounded-full flex items-center justify-center",
|
||||
iconBg,
|
||||
)}>
|
||||
{getModuleIcon(activity.module)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -bottom-1 -right-1 h-6 w-6 rounded-full border flex items-center justify-center",
|
||||
whiteBg,
|
||||
border,
|
||||
)}>
|
||||
{getActionIcon(activity.action)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h4
|
||||
className={cn("min-w-0 font-medium truncate", textStrong)}>
|
||||
{activity.description}
|
||||
</h4>
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
getStatusColor(activity.status),
|
||||
)}>
|
||||
{t(`auditLog.status.${activity.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 flex items-center gap-2 text-xs sm:hidden",
|
||||
textSubtle,
|
||||
)}>
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span className="whitespace-nowrap">{formattedDate}</span>
|
||||
<span className={cn(textSubtler)}>•</span>
|
||||
<span className="whitespace-nowrap">{formattedTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"hidden sm:flex items-center gap-2 text-xs whitespace-nowrap",
|
||||
textSubtle,
|
||||
)}>
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>{formattedDate}</span>
|
||||
<span className={cn(textSubtler)}>•</span>
|
||||
<span>{formattedTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center text-sm",
|
||||
textMuted,
|
||||
)}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Avatar className="h-6 w-6 sm:h-5 sm:w-5 shrink-0">
|
||||
{activity.performedBy.avatar ? (
|
||||
<AvatarImage src={activity.performedBy.avatar} />
|
||||
) : null}
|
||||
<AvatarFallback className="text-[10px] sm:text-xs">
|
||||
{activity.performedBy.name?.charAt(0)?.toUpperCase() || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium truncate max-w-[180px] sm:max-w-[220px]",
|
||||
textStrong,
|
||||
)}>
|
||||
{activity.performedBy.name === "System"
|
||||
? t("auditLog.page.systemUser")
|
||||
: activity.performedBy.name}
|
||||
</span>
|
||||
|
||||
<span className={cn("hidden sm:inline", textSubtler)}>•</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"truncate max-w-[220px] sm:max-w-[280px]",
|
||||
textSubtle,
|
||||
)}>
|
||||
{activity.performedBy.email}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{activity.resourceName ? (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={cn("hidden sm:inline", textSubtler)}>•</span>
|
||||
<span className={cn("sm:hidden", textSubtle)}>
|
||||
{t("auditLog.card.resource")}:
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate max-w-[260px] sm:max-w-[320px]",
|
||||
textMuted,
|
||||
)}>
|
||||
{activity.resourceName}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{activity.details || activity.ipAddress || activity.location ? (
|
||||
<div className={cn("mt-3 pt-3 border-t", borderLight)}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 text-xs">
|
||||
{activity.details ? (
|
||||
<div
|
||||
className="sm:col-span-2 min-w-0"
|
||||
title={activity.details}>
|
||||
<div className={cn("mb-0.5", textSubtler)}>
|
||||
{t("auditLog.card.details")}
|
||||
</div>
|
||||
<div className={cn("truncate", textSubtle)}>
|
||||
{activity.details}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-3 sm:justify-end sm:items-start">
|
||||
{activity.ipAddress ? (
|
||||
<div className="min-w-0">
|
||||
<div className={cn("mb-0.5", textSubtler)}>
|
||||
{t("auditLog.card.ip")}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"font-mono truncate max-w-[180px]",
|
||||
textSubtle,
|
||||
)}>
|
||||
{activity.ipAddress}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activity.location ? (
|
||||
<div className="min-w-0">
|
||||
<div className={cn("mb-0.5", textSubtler)}>
|
||||
{t("auditLog.card.location")}
|
||||
</div>
|
||||
<div
|
||||
className={cn("truncate max-w-[180px]", textSubtle)}>
|
||||
{activity.location}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Input } from '@/shared/common/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/shared/common/ui/select'
|
||||
import { Button } from '@/shared/common/ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/shared/common/ui/popover'
|
||||
import { Calendar } from '@/shared/common/ui/calendar'
|
||||
import { Search, Filter, CalendarIcon, X } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
interface ActivityFiltersProps {
|
||||
searchQuery: string
|
||||
onSearchChange: (value: string) => void
|
||||
moduleFilter: string
|
||||
onModuleChange: (value: string) => void
|
||||
actionFilter: string
|
||||
onActionChange: (value: string) => void
|
||||
statusFilter: string
|
||||
onStatusChange: (value: string) => void
|
||||
dateRange: { from?: Date; to?: Date }
|
||||
onDateRangeChange: (range: { from?: Date; to?: Date }) => void
|
||||
modules: string[]
|
||||
actions: string[]
|
||||
onClearFilters: () => void
|
||||
}
|
||||
|
||||
export function ActivityFilters({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
moduleFilter,
|
||||
onModuleChange,
|
||||
actionFilter,
|
||||
onActionChange,
|
||||
statusFilter,
|
||||
onStatusChange,
|
||||
dateRange,
|
||||
onDateRangeChange,
|
||||
modules,
|
||||
actions,
|
||||
onClearFilters,
|
||||
}: ActivityFiltersProps) {
|
||||
const { t } = useTranslation()
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false)
|
||||
|
||||
const textStrong = 'text-gray-900 dark:text-gray-100'
|
||||
const textMuted = 'text-gray-500 dark:text-gray-400'
|
||||
const textSubtle = 'text-gray-400 dark:text-gray-500'
|
||||
const hoverText = 'hover:text-gray-700 dark:hover:text-gray-300'
|
||||
const border = 'border-gray-200 dark:border-gray-800'
|
||||
const surface = 'bg-white dark:bg-gray-900'
|
||||
const placeholder =
|
||||
'placeholder:text-gray-400 dark:placeholder:text-gray-500'
|
||||
|
||||
const hasFilters =
|
||||
searchQuery ||
|
||||
moduleFilter !== 'all' ||
|
||||
actionFilter !== 'all' ||
|
||||
statusFilter !== 'all' ||
|
||||
dateRange.from ||
|
||||
dateRange.to
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={cn('text-lg font-semibold', textStrong)}>
|
||||
{t('auditLog.filters.title')}
|
||||
</h3>
|
||||
{hasFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearFilters}
|
||||
className={cn(textMuted, hoverText)}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
{t('auditLog.filters.clear')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className={cn(
|
||||
'absolute left-3 top-3 h-4 w-4',
|
||||
textSubtle,
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('auditLog.filters.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className={cn('pl-10', placeholder)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={moduleFilter} onValueChange={onModuleChange}>
|
||||
<SelectTrigger className={cn(border, surface)}>
|
||||
<SelectValue placeholder={t('auditLog.filters.module')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t('auditLog.filters.allModules')}
|
||||
</SelectItem>
|
||||
{modules.map((module) => (
|
||||
<SelectItem key={module} value={module}>
|
||||
{t(`activityLogPage.modules.${module}`, {
|
||||
defaultValue: module,
|
||||
})}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={actionFilter} onValueChange={onActionChange}>
|
||||
<SelectTrigger className={cn(border, surface)}>
|
||||
<SelectValue placeholder={t('auditLog.filters.action')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t('auditLog.filters.allActions')}
|
||||
</SelectItem>
|
||||
{actions.map((action) => (
|
||||
<SelectItem key={action} value={action}>
|
||||
{t(`activityLogPage.actions.${action}`, {
|
||||
defaultValue: action,
|
||||
})}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={statusFilter} onValueChange={onStatusChange}>
|
||||
<SelectTrigger className={cn(border, surface)}>
|
||||
<SelectValue placeholder={t('auditLog.filters.status')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t('auditLog.filters.allStatuses')}
|
||||
</SelectItem>
|
||||
<SelectItem value="success">
|
||||
{t('auditLog.status.success')}
|
||||
</SelectItem>
|
||||
<SelectItem value="failure">
|
||||
{t('auditLog.status.failure')}
|
||||
</SelectItem>
|
||||
<SelectItem value="warning">
|
||||
{t('auditLog.status.warning')}
|
||||
</SelectItem>
|
||||
<SelectItem value="pending">
|
||||
{t('auditLog.status.pending')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'w-full md:w-auto justify-start text-left font-normal',
|
||||
border,
|
||||
surface,
|
||||
textStrong,
|
||||
)}
|
||||
>
|
||||
<CalendarIcon
|
||||
className={cn('mr-2 h-4 w-4', textMuted)}
|
||||
/>
|
||||
{dateRange.from ? (
|
||||
dateRange.to ? (
|
||||
<>
|
||||
{format(dateRange.from, 'LLL dd, y')} -{' '}
|
||||
{format(dateRange.to, 'LLL dd, y')}
|
||||
</>
|
||||
) : (
|
||||
format(dateRange.from, 'LLL dd, y')
|
||||
)
|
||||
) : (
|
||||
<span className={textMuted}>
|
||||
{t('auditLog.filters.dateRange')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={cn('w-auto p-0', border, surface)}
|
||||
align="start"
|
||||
>
|
||||
<Calendar
|
||||
initialFocus
|
||||
mode="range"
|
||||
defaultMonth={dateRange.from}
|
||||
selected={{
|
||||
from: dateRange.from,
|
||||
to: dateRange.to,
|
||||
}}
|
||||
onSelect={(range) => {
|
||||
onDateRangeChange({
|
||||
from: range?.from,
|
||||
to: range?.to,
|
||||
})
|
||||
}}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const today = new Date()
|
||||
const last7Days = new Date(today)
|
||||
last7Days.setDate(last7Days.getDate() - 7)
|
||||
|
||||
onDateRangeChange({
|
||||
from: last7Days,
|
||||
to: today,
|
||||
})
|
||||
}}
|
||||
className={cn(border, surface, textStrong, hoverText)}
|
||||
>
|
||||
{t('auditLog.filters.last7Days')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const today = new Date()
|
||||
const last30Days = new Date(today)
|
||||
last30Days.setDate(last30Days.getDate() - 30)
|
||||
|
||||
onDateRangeChange({
|
||||
from: last30Days,
|
||||
to: today,
|
||||
})
|
||||
}}
|
||||
className={cn(border, surface, textStrong, hoverText)}
|
||||
>
|
||||
{t('auditLog.filters.last30Days')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Card, CardContent } from "@/shared/common/ui/card";
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ActivityStatsProps {
|
||||
totalActivities: number;
|
||||
successRate: number;
|
||||
avgDailyActivities: number;
|
||||
uniqueUsers: number;
|
||||
topActions: { action: string; count: number }[];
|
||||
}
|
||||
|
||||
export function ActivityStats({
|
||||
totalActivities,
|
||||
successRate,
|
||||
avgDailyActivities,
|
||||
uniqueUsers,
|
||||
topActions,
|
||||
}: ActivityStatsProps) {
|
||||
const border = "border-gray-200 dark:border-gray-800";
|
||||
const surface = "bg-white dark:bg-gray-900";
|
||||
const textStrong = "text-gray-900 dark:text-gray-100";
|
||||
const textMuted = "text-gray-600 dark:text-gray-300";
|
||||
const textSubtle = "text-gray-500 dark:text-gray-400";
|
||||
const textSubtler = "text-gray-400 dark:text-gray-500";
|
||||
const iconBgLight = "bg-gray-100 dark:bg-gray-800";
|
||||
const { t } = useTranslation();
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: t("auditLog.stats.totalActivities"),
|
||||
value: totalActivities.toLocaleString(),
|
||||
icon: Activity,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-50 dark:bg-blue-950",
|
||||
},
|
||||
{
|
||||
label: t("auditLog.stats.successRate"),
|
||||
value: `${successRate}%`,
|
||||
icon: CheckCircle,
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
bgColor: "bg-green-50 dark:bg-green-950",
|
||||
},
|
||||
{
|
||||
label: t("auditLog.stats.dailyAverage"),
|
||||
value: avgDailyActivities.toLocaleString(),
|
||||
icon: TrendingUp,
|
||||
color: "text-purple-600 dark:text-purple-400",
|
||||
bgColor: "bg-purple-50 dark:bg-purple-950",
|
||||
},
|
||||
{
|
||||
label: t("auditLog.stats.uniqueUsers"),
|
||||
value: uniqueUsers.toLocaleString(),
|
||||
icon: Clock,
|
||||
color: "text-orange-600 dark:text-orange-400",
|
||||
bgColor: "bg-orange-50 dark:bg-orange-950",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index} className={cn(border, surface)}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className={cn("text-sm font-medium", textMuted)}>
|
||||
{stat.label}
|
||||
</p>
|
||||
<p className={cn("text-2xl font-bold mt-1", textStrong)}>
|
||||
{stat.value}
|
||||
</p>
|
||||
</div>
|
||||
<div className={cn("p-3 rounded-full", stat.bgColor)}>
|
||||
<stat.icon className={cn("h-6 w-6", stat.color)} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className={cn(border, surface)}>
|
||||
<CardContent className="p-6">
|
||||
<h3 className={cn("text-lg font-semibold mb-4", textStrong)}>
|
||||
{t("auditLog.stats.topActions")}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{topActions.map((action, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-md flex items-center justify-center",
|
||||
iconBgLight,
|
||||
)}>
|
||||
<span className={cn("text-sm font-medium", textSubtle)}>
|
||||
{action.action.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<span className={cn("text-sm font-medium", textStrong)}>
|
||||
{t(`activityLogPage.actions.${action.action}`, {
|
||||
defaultValue: action.action,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<span className={cn("text-sm font-semibold", textMuted)}>
|
||||
{action.count} {t("auditLog.stats.times")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/common/ui/dialog'
|
||||
import { Button } from '@/shared/common/ui/button'
|
||||
import { Input } from '@/shared/common/ui/input'
|
||||
import { Label } from '@/shared/common/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@/shared/common/ui/radio-group'
|
||||
import { Checkbox } from '@/shared/common/ui/checkbox'
|
||||
import { Download, FileText, FileSpreadsheet } from 'lucide-react'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
interface ExportModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onExport: (options: ExportOptions) => void
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
format: 'csv' | 'json' | 'xlsx'
|
||||
includeDetails: boolean
|
||||
dateRange: boolean
|
||||
fileName: string
|
||||
}
|
||||
|
||||
export function ExportModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onExport,
|
||||
}: ExportModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [options, setOptions] = useState<ExportOptions>({
|
||||
format: 'csv',
|
||||
includeDetails: true,
|
||||
dateRange: true,
|
||||
fileName: `activity-logs-${new Date().toISOString().split('T')[0]}`,
|
||||
})
|
||||
|
||||
const surface = 'bg-white dark:bg-gray-900'
|
||||
const border = 'border-gray-200 dark:border-gray-800'
|
||||
const borderMuted = 'border-muted dark:border-gray-700'
|
||||
const textStrong = 'text-gray-900 dark:text-gray-100'
|
||||
const textMuted = 'text-gray-600 dark:text-gray-300'
|
||||
const textSubtle = 'text-gray-500 dark:text-gray-400'
|
||||
const primaryBorder = 'border-primary dark:border-sky-600'
|
||||
const hoverBg = 'hover:bg-accent dark:hover:bg-gray-800'
|
||||
const hoverText = 'hover:text-accent-foreground dark:hover:text-gray-100'
|
||||
const primaryBtn =
|
||||
'bg-primary dark:bg-sky-600 hover:bg-primary/90 dark:hover:bg-sky-500 text-white'
|
||||
|
||||
const handleExport = () => {
|
||||
onExport(options)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={cn('sm:max-w-[500px]', surface, border, textStrong)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className={textStrong}>
|
||||
{t('auditLog.export.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className={textMuted}>
|
||||
{t('auditLog.export.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label className={textStrong}>
|
||||
{t('auditLog.export.fileName')}
|
||||
</Label>
|
||||
<Input
|
||||
value={options.fileName}
|
||||
onChange={(e) =>
|
||||
setOptions({
|
||||
...options,
|
||||
fileName: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t('auditLog.export.fileNamePlaceholder')}
|
||||
className={cn(
|
||||
border,
|
||||
surface,
|
||||
textStrong,
|
||||
'placeholder:text-gray-400 dark:placeholder:text-gray-500',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Label className={textStrong}>
|
||||
{t('auditLog.export.format')}
|
||||
</Label>
|
||||
<RadioGroup
|
||||
value={options.format}
|
||||
onValueChange={(value: 'csv' | 'json' | 'xlsx') =>
|
||||
setOptions({ ...options, format: value })
|
||||
}
|
||||
className="grid grid-cols-3 gap-4"
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: 'csv',
|
||||
icon: FileText,
|
||||
label: t('auditLog.export.csv'),
|
||||
},
|
||||
{
|
||||
value: 'json',
|
||||
icon: FileText,
|
||||
label: t('auditLog.export.json'),
|
||||
},
|
||||
{
|
||||
value: 'xlsx',
|
||||
icon: FileSpreadsheet,
|
||||
label: t('auditLog.export.excel'),
|
||||
},
|
||||
].map(({ value, icon: Icon, label }) => (
|
||||
<div key={value}>
|
||||
<RadioGroupItem
|
||||
value={value}
|
||||
id={value}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={value}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-between rounded-md border-2 p-4 cursor-pointer transition-all',
|
||||
borderMuted,
|
||||
surface,
|
||||
hoverBg,
|
||||
hoverText,
|
||||
'peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary',
|
||||
options.format === value &&
|
||||
primaryBorder,
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'mb-3 h-6 w-6',
|
||||
textMuted,
|
||||
)}
|
||||
/>
|
||||
<span className={textStrong}>
|
||||
{label}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="includeDetails"
|
||||
checked={options.includeDetails}
|
||||
onCheckedChange={(checked) =>
|
||||
setOptions({
|
||||
...options,
|
||||
includeDetails: checked as boolean,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="includeDetails"
|
||||
className={cn('cursor-pointer', textStrong)}
|
||||
>
|
||||
{t('auditLog.export.includeDetails')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="dateRange"
|
||||
checked={options.dateRange}
|
||||
onCheckedChange={(checked) =>
|
||||
setOptions({
|
||||
...options,
|
||||
dateRange: checked as boolean,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="dateRange"
|
||||
className={cn('cursor-pointer', textStrong)}
|
||||
>
|
||||
{t('auditLog.export.includeDateRange')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className={cn(border, surface, textStrong, hoverBg)}
|
||||
>
|
||||
{t('auditLog.export.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleExport} className={cn(primaryBtn)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('auditLog.export.export')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { format } from "date-fns";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/shared/common/ui/avatar";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
import { AuditLogItem } from "./AuditLogDisplay";
|
||||
|
||||
type AuditLogDetailPanelProps = {
|
||||
log: AuditLogItem;
|
||||
onClose?: () => void;
|
||||
localizedName?: (name: { en: string; am: string }) => string;
|
||||
};
|
||||
|
||||
const getActionColor = (
|
||||
action: string,
|
||||
): "default" | "secondary" | "destructive" | "outline" => {
|
||||
const upper = action.toUpperCase();
|
||||
if (upper === "INSERT") return "default";
|
||||
if (upper === "UPDATE") return "secondary";
|
||||
if (upper === "DELETE") return "destructive";
|
||||
return "outline";
|
||||
};
|
||||
|
||||
const getEntityLabel = (entityName: string): string => {
|
||||
return entityName
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
const getInitials = (name: string): string => {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n.charAt(0))
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Detailed view of a single audit log entry
|
||||
* Shows full information including changes made
|
||||
*/
|
||||
export const AuditLogDetailPanel = ({
|
||||
log,
|
||||
onClose,
|
||||
localizedName,
|
||||
}: AuditLogDetailPanelProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isAmharic = i18n.language === "am";
|
||||
|
||||
const userName = isAmharic ? log.user.name.am : log.user.name.en;
|
||||
const displayName = localizedName
|
||||
? localizedName(log.user.name)
|
||||
: userName;
|
||||
|
||||
const timestamp = new Date(log.createdAt).toLocaleString(i18n.language, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle>{t("auditLog.detail.title", "Audit Log Details")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("auditLog.detail.description", "View details of this audit entry")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{onClose && (
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Header with user and action */}
|
||||
<div className="flex items-start gap-4 pb-4 border-b">
|
||||
<Avatar className="h-10 w-10 mt-1">
|
||||
<AvatarFallback>{getInitials(displayName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">{displayName}</h3>
|
||||
<p className="text-sm text-gray-600">{log.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action and Entity info */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
{t("auditLog.detail.action", "Action")}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<Badge variant={getActionColor(log.queryMethod)}>
|
||||
{log.queryMethod.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
{t("auditLog.detail.entity", "Entity")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm">{getEntityLabel(log.entityName)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
{t("auditLog.detail.timestamp", "Timestamp")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-gray-600">{timestamp}</p>
|
||||
</div>
|
||||
|
||||
{/* Changes if available */}
|
||||
{log.changes && log.changes.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 mb-3">
|
||||
{t("auditLog.detail.changes", "Changes Made")}
|
||||
</p>
|
||||
<div className="space-y-3 bg-gray-50 rounded-lg p-4">
|
||||
{log.changes.map((change, idx) => (
|
||||
<div key={idx} className="text-sm">
|
||||
<p className="font-medium text-gray-900 mb-1">
|
||||
{change.field ? String(change.field) : `Change ${idx + 1}`}
|
||||
</p>
|
||||
{change.from !== undefined && (
|
||||
<p className="text-gray-600">
|
||||
<span className="text-xs font-semibold">From:</span>{" "}
|
||||
<code className="bg-gray-100 px-2 py-1 rounded text-xs">
|
||||
{JSON.stringify(change.from)}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
{change.to !== undefined && (
|
||||
<p className="text-gray-600 mt-1">
|
||||
<span className="text-xs font-semibold">To:</span>{" "}
|
||||
<code className="bg-blue-50 px-2 py-1 rounded text-xs text-blue-900">
|
||||
{JSON.stringify(change.to)}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional info */}
|
||||
{log.user.username && (
|
||||
<div className="text-xs text-gray-500 pt-4 border-t">
|
||||
<p>
|
||||
<span className="font-semibold">Username:</span> {log.user.username}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
import { format } from "date-fns";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/common/ui/table";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/shared/common/ui/avatar";
|
||||
|
||||
export type AuditLogItem = {
|
||||
id?: string;
|
||||
createdAt: string;
|
||||
entityName: string;
|
||||
queryMethod: "INSERT" | "UPDATE" | "DELETE" | string;
|
||||
user: {
|
||||
id: string;
|
||||
name: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
email: string;
|
||||
username?: string;
|
||||
};
|
||||
changes?: Array<{
|
||||
field?: string;
|
||||
from?: unknown;
|
||||
to?: unknown;
|
||||
}>;
|
||||
};
|
||||
|
||||
type AuditLogDisplayProps = {
|
||||
logs: AuditLogItem[];
|
||||
loading?: boolean;
|
||||
localizedName?: (name: { en: string; am: string }) => string;
|
||||
onRowClick?: (log: AuditLogItem) => void;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const getActionColor = (
|
||||
action: string,
|
||||
): "default" | "secondary" | "destructive" | "outline" => {
|
||||
const upper = action.toUpperCase();
|
||||
if (upper === "INSERT") return "default";
|
||||
if (upper === "UPDATE") return "secondary";
|
||||
if (upper === "DELETE") return "destructive";
|
||||
return "outline";
|
||||
};
|
||||
|
||||
const getEntityLabel = (entityName: string): string => {
|
||||
// Convert snake_case to Title Case
|
||||
return entityName
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
const getInitials = (name: string): string => {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n.charAt(0))
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Displays audit logs in a table or compact format
|
||||
* Shows: WHO (user name) -> WHAT (action on entity) -> WHEN (timestamp)
|
||||
*/
|
||||
export const AuditLogDisplay = ({
|
||||
logs,
|
||||
loading = false,
|
||||
localizedName,
|
||||
onRowClick,
|
||||
compact = false,
|
||||
className,
|
||||
}: AuditLogDisplayProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isAmharic = i18n.language === "am";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center py-8", className)}>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!logs || logs.length === 0) {
|
||||
return (
|
||||
<div className={cn("text-center py-8 text-gray-500", className)}>
|
||||
<p>{t("auditLog.noRecords", "No audit records found")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{logs.map((log, idx) => {
|
||||
const userName = isAmharic ? log.user.name.am : log.user.name.en;
|
||||
const displayName = localizedName
|
||||
? localizedName(log.user.name)
|
||||
: userName;
|
||||
const timestamp = new Date(log.createdAt).toLocaleString(
|
||||
i18n.language,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={log.id || idx}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
onClick={() => onRowClick?.(log)}
|
||||
>
|
||||
<Avatar className="h-8 w-8 mt-0.5">
|
||||
<AvatarFallback className="text-xs">
|
||||
{getInitials(displayName)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm">{displayName}</span>
|
||||
<Badge variant={getActionColor(log.queryMethod)} className="text-xs">
|
||||
{log.queryMethod.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
{getEntityLabel(log.entityName)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{timestamp}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-gray-200 overflow-hidden", className)}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="w-[100px]">
|
||||
{t("auditLog.table.user", "User")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[150px]">
|
||||
{t("auditLog.table.action", "Action")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[200px]">
|
||||
{t("auditLog.table.entity", "Entity")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
{t("auditLog.table.time", "Timestamp")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.map((log, idx) => {
|
||||
const userName = isAmharic ? log.user.name.am : log.user.name.en;
|
||||
const displayName = localizedName
|
||||
? localizedName(log.user.name)
|
||||
: userName;
|
||||
const timestamp = new Date(log.createdAt).toLocaleString(
|
||||
i18n.language,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={log.id || idx}
|
||||
className="hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
onClick={() => onRowClick?.(log)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarFallback className="text-xs">
|
||||
{getInitials(displayName)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm font-medium truncate">
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getActionColor(log.queryMethod)}>
|
||||
{log.queryMethod.toUpperCase()}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{getEntityLabel(log.entityName)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-600">
|
||||
{timestamp}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card } from "@/shared/common/ui/card";
|
||||
import type { EtradeBusinessLicenseOption } from "@/complaints/services/etradeTinService";
|
||||
|
||||
interface EtradeLicensePickerProps {
|
||||
organizationName?: string;
|
||||
options: EtradeBusinessLicenseOption[];
|
||||
selectedLicenseNumber: string;
|
||||
onSelect: (licenseNumber: string) => void;
|
||||
}
|
||||
|
||||
export function EtradeLicensePicker({
|
||||
organizationName,
|
||||
options,
|
||||
selectedLicenseNumber,
|
||||
onSelect,
|
||||
}: EtradeLicensePickerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{t("registration.etrade.selectLicenseTitle")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
{t("registration.etrade.selectLicenseDescription")}
|
||||
</p>
|
||||
{organizationName ? (
|
||||
<p className="mt-2 text-sm font-medium text-gray-900">
|
||||
{organizationName}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[24rem] space-y-3 overflow-y-auto pr-1">
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedLicenseNumber === option.licenseNumber;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={option.mainGuid}
|
||||
className={`cursor-pointer border-2 p-4 transition-all ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 ring-2 ring-primary/20"
|
||||
: "border-gray-200 hover:border-primary/40 bg-white"
|
||||
}`}
|
||||
onClick={() => onSelect(option.licenseNumber)}>
|
||||
<div className="space-y-2 text-left">
|
||||
<p className="font-semibold text-gray-900">{option.tradeName}</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
<span className="font-medium">
|
||||
{t("registration.etrade.licenseNumber")}:
|
||||
</span>{" "}
|
||||
<span className="font-mono">{option.licenseNumber}</span>
|
||||
</p>
|
||||
{option.activities.length > 0 ? (
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium">
|
||||
{t("registration.etrade.activity")}:
|
||||
</span>{" "}
|
||||
{option.activities.join("; ")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import * as React from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import EthiopianDatePickerModal, {
|
||||
convertEthiopianToGregorian,
|
||||
} from "@/shared/common/form/fields/AmharicDatePicker";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { FilterParams, FilterValue } from "@/shared/utils/filterParams";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
export type CombinedFilterField =
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "text";
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "dateRange";
|
||||
fromKey: string;
|
||||
toKey: string;
|
||||
};
|
||||
|
||||
type CombinedFilterBarProps = {
|
||||
fields: CombinedFilterField[];
|
||||
filters: Partial<FilterParams>;
|
||||
setFilter: (key: string, value: FilterValue) => void;
|
||||
setFilters: (filters: Partial<FilterParams>) => void;
|
||||
removeFilter: (key: string) => void;
|
||||
removeFilters: (keys: string[]) => void;
|
||||
onFilterChange?: () => void;
|
||||
className?: string;
|
||||
onLiveSearch?: (key: string, value: string) => void;
|
||||
};
|
||||
|
||||
type ActiveFilterChip = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
remove: () => void;
|
||||
};
|
||||
|
||||
export function CombinedFilterBar({
|
||||
fields,
|
||||
filters,
|
||||
setFilter,
|
||||
setFilters,
|
||||
removeFilter,
|
||||
removeFilters,
|
||||
onFilterChange,
|
||||
className,
|
||||
onLiveSearch,
|
||||
}: CombinedFilterBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedFilterKey, setSelectedFilterKey] = React.useState(
|
||||
fields[0]?.key ?? "",
|
||||
);
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
const [fromDate, setFromDate] = React.useState("");
|
||||
const [toDate, setToDate] = React.useState("");
|
||||
const [fromDateDisplay, setFromDateDisplay] = React.useState("");
|
||||
const [toDateDisplay, setToDateDisplay] = React.useState("");
|
||||
const [showFromPicker, setShowFromPicker] = React.useState(false);
|
||||
const [showToPicker, setShowToPicker] = React.useState(false);
|
||||
const lang = i18n.language;
|
||||
|
||||
const isFilterActive = React.useCallback(
|
||||
(field: CombinedFilterField) => {
|
||||
if (field.type === "dateRange") {
|
||||
return Boolean(filters[field.fromKey] || filters[field.toKey]);
|
||||
}
|
||||
|
||||
return Boolean(filters[field.key]);
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
|
||||
const availableFields = React.useMemo(
|
||||
() => fields.filter((field) => !isFilterActive(field)),
|
||||
[fields, isFilterActive],
|
||||
);
|
||||
|
||||
const selectedFilter = React.useMemo(
|
||||
() => availableFields.find((field) => field.key === selectedFilterKey),
|
||||
[availableFields, selectedFilterKey],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedFilter || availableFields.length === 0) return;
|
||||
|
||||
setSelectedFilterKey(availableFields[0].key);
|
||||
clearDraftValues();
|
||||
}, [availableFields, selectedFilter]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedFilter || selectedFilter.type === "dateRange") return;
|
||||
|
||||
const value = inputValue.trim();
|
||||
|
||||
if (value.length < 2) {
|
||||
onLiveSearch?.(selectedFilter.key, ""); // clear when too short
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = setTimeout(() => {
|
||||
onLiveSearch?.(selectedFilter.key, value);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(handler);
|
||||
}, [inputValue, selectedFilter]);
|
||||
const clearDraftValues = () => {
|
||||
setInputValue("");
|
||||
setFromDate("");
|
||||
setToDate("");
|
||||
setFromDateDisplay("");
|
||||
setToDateDisplay("");
|
||||
onLiveSearch?.("", "");
|
||||
};
|
||||
|
||||
const addFilter = React.useCallback(() => {
|
||||
if (!selectedFilter) return;
|
||||
|
||||
if (selectedFilter.type === "dateRange") {
|
||||
if (!fromDate && !toDate) return;
|
||||
|
||||
setFilters({
|
||||
[selectedFilter.fromKey]: fromDate,
|
||||
[selectedFilter.toKey]: toDate,
|
||||
});
|
||||
clearDraftValues();
|
||||
onFilterChange?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const value = inputValue.trim();
|
||||
if (!value) return;
|
||||
|
||||
setFilter(selectedFilter.key, value);
|
||||
clearDraftValues();
|
||||
onFilterChange?.();
|
||||
}, [
|
||||
fromDate,
|
||||
inputValue,
|
||||
onFilterChange,
|
||||
selectedFilter,
|
||||
setFilter,
|
||||
setFilters,
|
||||
toDate,
|
||||
]);
|
||||
|
||||
const chips = React.useMemo<ActiveFilterChip[]>(
|
||||
() =>
|
||||
fields.reduce<ActiveFilterChip[]>((items, field) => {
|
||||
if (field.type === "dateRange") {
|
||||
const from = filters[field.fromKey];
|
||||
const to = filters[field.toKey];
|
||||
if (!from && !to) return items;
|
||||
|
||||
items.push({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
value: [
|
||||
from ? `${t("common.from", "From")}: ${from}` : "",
|
||||
to ? `${t("common.to", "To")}: ${to}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" - "),
|
||||
remove: () => removeFilters([field.fromKey, field.toKey]),
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
const value = filters[field.key];
|
||||
if (!value) return items;
|
||||
|
||||
items.push({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
value: String(value),
|
||||
remove: () => removeFilter(field.key),
|
||||
});
|
||||
return items;
|
||||
}, []),
|
||||
[fields, filters, removeFilter, removeFilters, t],
|
||||
);
|
||||
|
||||
const handleRemove = (remove: () => void) => {
|
||||
remove();
|
||||
onFilterChange?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className ?? "flex w-full min-w-0 flex-col gap-2"}>
|
||||
<div className="flex w-full min-w-0 flex-col flex-wrap items-stretch gap-2 sm:flex-row sm:items-center">
|
||||
<Select
|
||||
value={selectedFilter?.key ?? ""}
|
||||
onValueChange={(value) => {
|
||||
setSelectedFilterKey(value);
|
||||
clearDraftValues();
|
||||
}}
|
||||
disabled={availableFields.length === 0}>
|
||||
<SelectTrigger className="w-full sm:w-[200px]">
|
||||
<SelectValue placeholder={t("userRecord.Filter by")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableFields.map((field) => (
|
||||
<SelectItem key={field.key} value={field.key}>
|
||||
{field.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedFilter?.type === "dateRange" ? (
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
{lang.startsWith("en") ? (
|
||||
<>
|
||||
<label className="mt-2">From: </label>
|
||||
<Input
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(event) => setFromDate(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") addFilter();
|
||||
}}
|
||||
className="w-[150px]"
|
||||
/>
|
||||
<label className="mt-2">To: </label>
|
||||
<Input
|
||||
type="date"
|
||||
value={toDate}
|
||||
onChange={(event) => setToDate(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") addFilter();
|
||||
}}
|
||||
min={fromDate}
|
||||
className="w-[150px]"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative">
|
||||
<label className="font-medium mr-2">ከ:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fromDateDisplay}
|
||||
readOnly
|
||||
onClick={() => setShowFromPicker((prev) => !prev)}
|
||||
className="border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1.5 w-40 bg-white dark:bg-gray-800 cursor-pointer"
|
||||
placeholder="ቀን ይምረጡ"
|
||||
/>
|
||||
{showFromPicker && (
|
||||
<div className="absolute z-50 mt-1 bg-white dark:bg-gray-800 shadow-lg rounded-md p-2">
|
||||
<EthiopianDatePickerModal
|
||||
value={fromDate}
|
||||
onChange={(ethiopianDate: string) => {
|
||||
const gregorianDate =
|
||||
convertEthiopianToGregorian(ethiopianDate);
|
||||
const formatted = gregorianDate
|
||||
.toISOString()
|
||||
.split("T")[0];
|
||||
setFromDate(formatted);
|
||||
setFromDateDisplay(ethiopianDate);
|
||||
setShowFromPicker(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<label className="font-medium mr-2">እስከ:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={toDateDisplay}
|
||||
readOnly
|
||||
onClick={() => setShowToPicker((prev) => !prev)}
|
||||
className="border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1.5 w-40 bg-white dark:bg-gray-800 cursor-pointer"
|
||||
placeholder="ቀን ይምረጡ"
|
||||
/>
|
||||
{showToPicker && (
|
||||
<div className="absolute z-50 mt-1 bg-white dark:bg-gray-800 shadow-lg rounded-md p-2">
|
||||
<EthiopianDatePickerModal
|
||||
value={toDate}
|
||||
onChange={(ethiopianDate: string) => {
|
||||
const gregorianDate =
|
||||
convertEthiopianToGregorian(ethiopianDate);
|
||||
const formatted = gregorianDate
|
||||
.toISOString()
|
||||
.split("T")[0];
|
||||
setToDate(formatted);
|
||||
setToDateDisplay(ethiopianDate);
|
||||
setShowToPicker(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative w-full sm:w-[200px]">
|
||||
<Input
|
||||
type="text"
|
||||
className="pl-8"
|
||||
placeholder={`${t("userRecord.Search")} ${
|
||||
selectedFilter?.label.toLowerCase() ?? ""
|
||||
}`}
|
||||
value={inputValue}
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") addFilter();
|
||||
}}
|
||||
disabled={!selectedFilter}
|
||||
/>
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
onClick={addFilter}
|
||||
disabled={
|
||||
!selectedFilter ||
|
||||
(selectedFilter.type === "dateRange"
|
||||
? !fromDate && !toDate
|
||||
: !inputValue.trim())
|
||||
}>
|
||||
{t("common.add", "Add Filter")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{chips.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{chips.map((filter) => (
|
||||
<Badge
|
||||
key={filter.key}
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-sm">
|
||||
<span className="font-medium">{filter.label}:</span>
|
||||
<span>{filter.value}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(filter.remove)}
|
||||
className="ml-1 rounded-sm p-0.5 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
aria-label={`${t("common.remove", "Remove")} ${filter.label}`}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Lock, ArrowLeft, RefreshCw, Eye, EyeOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { changePassword } from "@/shared/services/authService";
|
||||
import Cookies from "js-cookie";
|
||||
import { useAuthUser } from "@/record-management/hooks/useAuthUser";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const ResetPassword = () => {
|
||||
const navigate = useNavigate();
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const { logout } = useAuthUser();
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// Validate old password
|
||||
if (!oldPassword) {
|
||||
setError(t("msg.currentPasswordRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate new password
|
||||
if (!newPassword) {
|
||||
setError(t("msg.newPasswordRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError(t("msg.passwordMinLength"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if new password is same as old password
|
||||
if (newPassword === oldPassword) {
|
||||
setError(t("msg.passwordMustBeDifferent"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate confirm password
|
||||
if (!confirmPassword) {
|
||||
setError(t("msg.confirmPasswordRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(t("msg.passwordMismatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for password complexity
|
||||
const hasUpperCase = /[A-Z]/.test(newPassword);
|
||||
const hasLowerCase = /[a-z]/.test(newPassword);
|
||||
const hasNumbers = /\d/.test(newPassword);
|
||||
const hasSpecialChar = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(newPassword);
|
||||
|
||||
const complexityErrors = [];
|
||||
if (!hasUpperCase) complexityErrors.push(t("msg.uppercaseLetter"));
|
||||
if (!hasLowerCase) complexityErrors.push(t("msg.lowercaseLetter"));
|
||||
if (!hasNumbers) complexityErrors.push(t("msg.number"));
|
||||
if (!hasSpecialChar) complexityErrors.push(t("msg.specialCharacter"));
|
||||
|
||||
if (complexityErrors.length > 0) {
|
||||
setError(t("msg.passwordComplexity") + " " + complexityErrors.join(", "));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await changePassword({
|
||||
oldPassword,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
});
|
||||
|
||||
toast.success(t("msg.successChange"), {
|
||||
description: "Your password has been changed successfully",
|
||||
});
|
||||
|
||||
// Redirect to login page after successful reset
|
||||
setTimeout(handleLogout, 1200);
|
||||
} catch (error: any) {
|
||||
const message = error?.message || t("msg.failedChange");
|
||||
setError(message);
|
||||
toast.error(t("msg.failedChange"), {
|
||||
description: message,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center bg-gray-100 dark:bg-gray-900 p-4"
|
||||
style={{ height: "100vh" }}
|
||||
>
|
||||
<div className="w-full max-w-md bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8">
|
||||
<div className="mb-8 text-center">
|
||||
<img
|
||||
src="/assets/smart-office-logo.svg"
|
||||
alt="Smart Office Logo"
|
||||
className="h-10 mb-6 mx-auto"
|
||||
/>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||
{t("change_password")}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("msg.changePasswordMsg")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t("msg.oldPassword")}
|
||||
className="h-10 rounded-md border dark:border-gray-600 px-4 text-sm ps-10 dark:bg-gray-700 dark:text-gray-100"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t("msg.newPassword")}
|
||||
className="h-10 rounded-md border dark:border-gray-600 px-4 text-sm ps-10 dark:bg-gray-700 dark:text-gray-100"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<Input
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder={t("msg.confirmNewPassword")}
|
||||
className="h-10 rounded-md border dark:border-gray-600 px-4 text-sm ps-10 dark:bg-gray-700 dark:text-gray-100"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3 md:p-4">
|
||||
<p className="text-sm text-red-700 dark:text-red-400 font-medium">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-10 bg-primary hover:bg-primary-300 text-white text-sm"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<RefreshCw className="animate-spin h-5 w-5 mr-2" />
|
||||
changing...
|
||||
</span>
|
||||
) : (
|
||||
t("change_password")
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-10 text-sm"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
{t("userRecord.Back")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPassword;
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Phone, ArrowLeft, RefreshCw, Mail, Lock, Home } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isValidEthiopianPhone } from "@/record-management/common/editor/Utils";
|
||||
import { requestForgotPassword } from "@/shared/services/authService";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const ForgotPassword = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
let processedIdentifier = identifier;
|
||||
|
||||
if (!isValidEthiopianPhone(processedIdentifier)) {
|
||||
setError(t("forgotpassword.invalidphone"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (processedIdentifier.startsWith("0")) {
|
||||
processedIdentifier = "+251" + processedIdentifier.slice(1);
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await requestForgotPassword(processedIdentifier);
|
||||
toast.success(t("forgotpassword.success"), {
|
||||
description: t("forgotpassword.successdesc"),
|
||||
});
|
||||
setTimeout(() => navigate("/"), 3000);
|
||||
} catch (error: any) {
|
||||
const message = error?.message || t("forgotpassword.fail");
|
||||
setError(message);
|
||||
toast.error(t("forgotpassword.fail"), { description: message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-cyan-50 via-white to-primary-50 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-cyan-100/30 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 left-0 w-96 h-96 bg-primary-100/30 rounded-full blur-3xl" />
|
||||
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
className="absolute top-6 left-6 z-10 flex items-center gap-2 px-4 py-2 bg-white/80 backdrop-blur-sm hover:bg-white rounded-full shadow-md hover:shadow-lg transition-all duration-300 group"
|
||||
>
|
||||
<Home className="w-4 h-4 text-primary group-hover:scale-110 transition-transform" />
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{t("forgotpassword.home")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="relative min-h-screen flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-primary to-primary-500 p-8 text-center relative">
|
||||
<div className="absolute inset-0 bg-white/5" />
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Lock className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">
|
||||
{t("forgotpassword.title")}
|
||||
</h1>
|
||||
<p className="text-cyan-50 text-sm">
|
||||
{t("forgotpassword.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<Phone className="w-4 h-4 text-primary" />
|
||||
{t("forgotpassword.phone")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="tel"
|
||||
placeholder={t("forgotpassword.phoneplaceholder")}
|
||||
className="h-12 rounded-lg border-gray-200 px-4 text-sm focus:border-primary focus:ring-primary transition-all"
|
||||
value={identifier}
|
||||
onChange={(e) => {
|
||||
setIdentifier(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 border border-red-100 rounded-lg">
|
||||
<div className="w-1 h-1 bg-red-500 rounded-full mt-1.5" />
|
||||
<p className="text-sm text-red-600 flex-1">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-4 bg-cyan-50 border border-cyan-100 rounded-lg">
|
||||
<Mail className="w-5 h-5 text-primary flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-700 font-medium mb-1">
|
||||
{t("forgotpassword.checkphone")}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">
|
||||
{t("forgotpassword.checkdesc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 bg-primary hover:bg-primary-500 text-white text-sm font-medium rounded-lg shadow-md hover:shadow-lg transition-all duration-300"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<RefreshCw className="animate-spin h-5 w-5" />
|
||||
{t("forgotpassword.sending")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
{t("forgotpassword.sendresetlink")}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-12 text-sm font-medium border-gray-200 hover:bg-gray-50 rounded-lg transition-all duration-300 bg-transparent"
|
||||
onClick={() => navigate("/login")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
{t("forgotpassword.backtologin")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-6">
|
||||
{t("forgotpassword.remember")}{" "}
|
||||
<button
|
||||
onClick={() => navigate("/login")}
|
||||
className="text-primary hover:text-primary-500 font-medium transition-colors"
|
||||
>
|
||||
{t("forgotpassword.signin")}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPassword;
|
||||
@@ -0,0 +1,349 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Lock,
|
||||
Mail,
|
||||
Phone,
|
||||
User,
|
||||
Moon,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { useAuthUser } from "@/shared/hooks/useAuthUser";
|
||||
import { useTenantConfig } from "@/layout/components/TenantConfig";
|
||||
import {
|
||||
isValidEmail,
|
||||
isValidEthiopianPhoneNum,
|
||||
} from "@/record-management/common/editor/Utils";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import OTPModal from "./OTPModal";
|
||||
import { useDarkMode } from "@/shared/hooks/useDarkMode";
|
||||
import { getRememberMePreference } from "@/shared/utils/authPersistence";
|
||||
|
||||
export const Login = () => {
|
||||
const { login: authLogin, isLoggingIn } = useAuthUser();
|
||||
const { config } = useTenantConfig();
|
||||
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
||||
const detectLoginMethod = (value: string): "email" | "phone" | "username" => {
|
||||
if (value.includes("@") && value.includes(".com")) return "email";
|
||||
if (/^\+?\d+$/.test(value)) return "phone";
|
||||
return "username";
|
||||
};
|
||||
const [otpVerify, setOtpVerify] = useState("");
|
||||
const { showOtpModal, setShowOtpModal, setRememberMePreference } = useAuth();
|
||||
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordType, setPasswordType] = useState("password");
|
||||
const [phoneError, setPhoneError] = useState("");
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const savedRememberMe = getRememberMePreference();
|
||||
setRememberMe(savedRememberMe);
|
||||
setRememberMePreference(savedRememberMe);
|
||||
}, [setRememberMePreference]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPhoneError("");
|
||||
try {
|
||||
let processedIdentifier = identifier;
|
||||
const loginMethod = detectLoginMethod(identifier);
|
||||
if (loginMethod === "phone") {
|
||||
if (!isValidEthiopianPhoneNum(processedIdentifier)) {
|
||||
setPhoneError(t("auth.err"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (processedIdentifier.startsWith("0")) {
|
||||
processedIdentifier = "+251" + processedIdentifier.slice(1);
|
||||
}
|
||||
} else if (loginMethod === "email") {
|
||||
if (!isValidEmail(processedIdentifier)) {
|
||||
setPhoneError(t("auth.invalidEmail"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The backend identifies users via the email field regardless of login method
|
||||
const payload: { email: string; password: string } = {
|
||||
email: processedIdentifier,
|
||||
password,
|
||||
};
|
||||
setOtpVerify(processedIdentifier);
|
||||
setRememberMePreference(rememberMe);
|
||||
authLogin({ payload, rememberMeValue: rememberMe });
|
||||
} catch (error) {
|
||||
console.error("Login failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getInputIcon = () => {
|
||||
const method = detectLoginMethod(identifier);
|
||||
switch (method) {
|
||||
case "email":
|
||||
return (
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500" />
|
||||
);
|
||||
case "phone":
|
||||
return (
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500" />
|
||||
);
|
||||
case "username":
|
||||
return (
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500" />
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4 md:p-6 lg:p-8 flex items-center justify-center">
|
||||
<Link
|
||||
to="/"
|
||||
className="fixed top-4 left-4 md:top-6 md:left-6 z-50 flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 rounded-full shadow-md hover:shadow-lg transition-all duration-300 hover:scale-105 text-gray-700 dark:text-gray-200 hover:text-primary group"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-600 dark:text-gray-400 transition-transform group-hover:-translate-x-1" />
|
||||
<span className="text-sm font-medium hidden sm:inline">
|
||||
{t("Back to Home")}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Dark Mode Toggle */}
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
className="fixed top-4 right-4 md:top-6 md:right-6 z-50 p-2 rounded-full bg-white dark:bg-gray-800 shadow-md hover:shadow-lg transition-all duration-300 hover:scale-105 border border-gray-200 dark:border-gray-700"
|
||||
title={isDarkMode ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{isDarkMode ? (
|
||||
<Sun className="w-5 h-5 text-yellow-500" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="w-full max-w-6xl bg-white dark:bg-gray-800 rounded-2xl shadow-2xl overflow-hidden flex flex-col lg:flex-row">
|
||||
{/* Left Panel - Login Form */}
|
||||
<div className="lg:w-1/2 w-full p-6 sm:p-8 md:p-10 lg:p-12 xl:p-16 flex flex-col justify-center">
|
||||
<div className="mb-8">
|
||||
<img
|
||||
src={config.logo || "/assets/smart-office-logo.svg"}
|
||||
alt="Smart Office Logo"
|
||||
className="h-10 md:h-12 mb-8"
|
||||
/>
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
{t("auth.welcomeBack")}
|
||||
</h1>
|
||||
<p className="text-base text-gray-500 dark:text-gray-400">
|
||||
{t("auth.enterCredentials")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
{/* Identifier Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t("auth.email") +
|
||||
" / " +
|
||||
t("auth.phoneNumber") +
|
||||
" / " +
|
||||
t("auth.username")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
{getInputIcon()}
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={
|
||||
t("auth.email") +
|
||||
" / " +
|
||||
t("auth.phoneNumber") +
|
||||
" / " +
|
||||
t("auth.username")
|
||||
}
|
||||
className="h-12 rounded-lg border-2 border-gray-200 dark:border-gray-600 px-4 text-sm pl-11 pr-4 focus:border-primary transition-colors text-gray-900 dark:text-white bg-white dark:bg-gray-700 placeholder-gray-500 dark:placeholder-gray-400"
|
||||
value={identifier}
|
||||
onChange={(e) => {
|
||||
setIdentifier(e.target.value.trim());
|
||||
if (phoneError) setPhoneError("");
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{phoneError && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-2 flex items-center gap-1">
|
||||
<span className="w-1 h-1 bg-red-500 dark:bg-red-400 rounded-full"></span>
|
||||
{phoneError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t("auth.password")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500" />
|
||||
<Input
|
||||
type={passwordType}
|
||||
placeholder={t("auth.password")}
|
||||
className="h-12 rounded-lg border-2 border-gray-200 dark:border-gray-600 px-4 text-sm pl-11 pr-4 focus:border-primary transition-colors text-gray-900 dark:text-white bg-white dark:bg-gray-700 placeholder-gray-500 dark:placeholder-gray-400"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setPasswordType(
|
||||
passwordType === "password" ? "text" : "password",
|
||||
)
|
||||
}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
{passwordType === "password" ? (
|
||||
<Eye className="w-5 h-5" />
|
||||
) : (
|
||||
<EyeOff className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remember Me & Forgot Password */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 accent-primary cursor-pointer bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-500"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-gray-200 transition-colors">
|
||||
{t("auth.rememberMe")}
|
||||
</span>
|
||||
</label>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-sm text-primary hover:text-primary-700 font-medium transition-colors"
|
||||
>
|
||||
{t("auth.forgotPassword")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 bg-primary hover:bg-primary-700 text-white text-base font-medium rounded-lg shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
disabled={isLoggingIn}
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
{t("auth.loggingIn")}
|
||||
</span>
|
||||
) : (
|
||||
t("auth.login")
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Sign Up Link */}
|
||||
<div className="text-center pt-2 space-y-2">
|
||||
<div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t("registration.auth.externalUserPrompt")}{" "}
|
||||
</span>
|
||||
<Link
|
||||
to="/external-portal/signin"
|
||||
className="text-sm text-primary hover:text-primary-700 font-medium transition-colors"
|
||||
>
|
||||
{t("registration.auth.signInWithProvider")}
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t("Don't have an account?")}{" "}
|
||||
</span>
|
||||
<Link
|
||||
to="/external-portal/signup"
|
||||
className="text-sm text-primary hover:text-primary-700 font-medium transition-colors"
|
||||
>
|
||||
{t("Sign Up")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Marketing Content */}
|
||||
<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-primary to-primary-700 text-white flex-col justify-center p-10 xl:p-16 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-white/10 rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-0 left-0 w-96 h-96 bg-white/5 rounded-full blur-3xl"></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-3xl xl:text-4xl font-bold mb-4 leading-tight">
|
||||
{t("auth.welcomeHeadline")} <br /> {t("auth.paperlessOffice")}
|
||||
</h2>
|
||||
<p className="text-base xl:text-lg mb-8 text-white/90 leading-relaxed">
|
||||
{t("auth.welcomeSubtext")}
|
||||
</p>
|
||||
|
||||
{/* Dashboard Preview */}
|
||||
<div className="relative w-full max-w-lg mx-auto mt-12">
|
||||
<div className="relative rounded-2xl overflow-hidden shadow-2xl border-4 border-white/20">
|
||||
<img
|
||||
src={
|
||||
config.dashboardPreviewImage ||
|
||||
"/assets/MainDashboard.png"
|
||||
}
|
||||
alt="Main Dashboard"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showOtpModal && (
|
||||
<OTPModal
|
||||
isOpen={true}
|
||||
onClose={() => setShowOtpModal(false)}
|
||||
identifier={otpVerify}
|
||||
identifierType={detectLoginMethod(otpVerify)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { useAuthUser } from "@/shared/hooks/useAuthUser";
|
||||
import {
|
||||
isValidEmail,
|
||||
isValidEthiopianPhoneNum,
|
||||
} from "@/record-management/common/editor/Utils";
|
||||
|
||||
interface OTPModalProps {
|
||||
isOpen: boolean;
|
||||
identifier: string; // previously email
|
||||
identifierType: "email" | "phone" | "username"; // new
|
||||
onClose: () => void;
|
||||
isLoading?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
length?: number;
|
||||
}
|
||||
|
||||
const OTPModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
isLoading = false,
|
||||
title,
|
||||
description,
|
||||
length = 6,
|
||||
identifier,
|
||||
identifierType,
|
||||
}: OTPModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [otp, setOtp] = useState<string[]>(Array(length).fill(""));
|
||||
|
||||
const inputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
|
||||
const { verifyMFA } = useAuthUser();
|
||||
// Default translations
|
||||
const modalTitle = title || t("otp.verifyIdentity") || "Verify Your Identity";
|
||||
const modalDescription =
|
||||
description ||
|
||||
t("otp.enterCodeSent") ||
|
||||
"Enter the verification code sent to your email";
|
||||
const verifyText = t("common.verify") || "Verify";
|
||||
const cancelText = t("common.cancel") || "Cancel";
|
||||
|
||||
const [phoneError, setPhoneError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
inputRefs.current[0]?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Reset OTP when modal opens
|
||||
setOtp(Array(length).fill(""));
|
||||
// Focus first input
|
||||
setTimeout(() => {
|
||||
inputRefs.current[0]?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}, [isOpen, length]);
|
||||
|
||||
const handleChange = (index: number, value: string) => {
|
||||
if (!/^[a-zA-Z0-9]?$/.test(value)) return; // Only allow numbers
|
||||
|
||||
const newOtp = [...otp];
|
||||
newOtp[index] = value;
|
||||
setOtp(newOtp);
|
||||
|
||||
// Auto-focus next input
|
||||
if (value && index < length - 1) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
|
||||
// Auto-submit if all fields are filled
|
||||
if (newOtp.every((digit) => digit !== "") && index === length - 1) {
|
||||
handleVerify(newOtp.join(""));
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (
|
||||
index: number,
|
||||
e: React.KeyboardEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (e.key === "Backspace" && !otp[index] && index > 0) {
|
||||
// Move to previous input on backspace
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
const pastedData = e.clipboardData.getData("text").slice(0, length);
|
||||
const pastedArray = pastedData
|
||||
.split("")
|
||||
.filter((char) => /^\d?$/.test(char));
|
||||
|
||||
if (pastedArray.length > 0) {
|
||||
const newOtp = [...otp];
|
||||
pastedArray.forEach((char, index) => {
|
||||
if (index < length) {
|
||||
newOtp[index] = char;
|
||||
}
|
||||
});
|
||||
setOtp(newOtp);
|
||||
|
||||
// Focus next empty input or last input
|
||||
const nextEmptyIndex = newOtp.findIndex((digit) => digit === "");
|
||||
const focusIndex =
|
||||
nextEmptyIndex === -1
|
||||
? length - 1
|
||||
: Math.min(nextEmptyIndex, length - 1);
|
||||
inputRefs.current[focusIndex]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = (verificationCode: string) => {
|
||||
// Make sure identifier is defined and valid based on login method
|
||||
let processedIdentifier = identifier;
|
||||
if (identifierType === "phone") {
|
||||
if (!isValidEthiopianPhoneNum(processedIdentifier)) {
|
||||
setPhoneError(t("auth.err"));
|
||||
return;
|
||||
}
|
||||
// Convert to international format if starts with 0
|
||||
if (processedIdentifier.startsWith("0")) {
|
||||
processedIdentifier = "+251" + processedIdentifier.slice(1);
|
||||
}
|
||||
} else if (identifierType === "email") {
|
||||
if (!isValidEmail(processedIdentifier)) {
|
||||
setPhoneError("Please enter a valid email address");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Payload always uses 'email' key
|
||||
const payload = {
|
||||
email: processedIdentifier,
|
||||
otp: verificationCode,
|
||||
};
|
||||
|
||||
verifyMFA(payload);
|
||||
};
|
||||
|
||||
const clearOtp = () => {
|
||||
setOtp(Array(length).fill(""));
|
||||
inputRefs.current[0]?.focus();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm dark:bg-black/80">
|
||||
<Card className="w-96 shadow-2xl border-0 animate-in fade-in-90 zoom-in-90 bg-white dark:bg-gray-800">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-xl dark:text-white">{modalTitle}</CardTitle>
|
||||
<CardDescription className="dark:text-gray-400">{modalDescription}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* OTP Input Fields */}
|
||||
<div className="flex justify-center gap-2">
|
||||
{otp.map((digit, index) => (
|
||||
<Input
|
||||
key={index}
|
||||
ref={(el: HTMLInputElement | null) => {
|
||||
if (el) inputRefs.current[index] = el; // assign only if not null
|
||||
}}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChange={(e) => handleChange(index, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||
onPaste={handlePaste}
|
||||
className="w-12 h-12 text-center text-lg font-semibold focus:ring-2 focus:ring-primary-500 border-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white border-gray-200 dark:border-gray-600 dark:focus:border-primary-500"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="flex-1 hover:bg-primary-100 dark:hover:bg-primary-900/30 transition-all duration-200 border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300">
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleVerify(otp.join(""))}
|
||||
disabled={isLoading || otp.join("").length !== length}
|
||||
className="flex-1 bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 text-white shadow-lg hover:shadow-xl transition-all duration-200 disabled:opacity-50">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{t("common.verifying") || "Verifying..."}
|
||||
</div>
|
||||
) : (
|
||||
verifyText
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Helper Actions */}
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearOtp}
|
||||
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium transition-colors disabled:opacity-50"
|
||||
disabled={isLoading}>
|
||||
{t("otp.clearCode") || "Clear Code"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium transition-colors disabled:opacity-50"
|
||||
disabled={isLoading}>
|
||||
{t("otp.resendCode") || "Resend Code"}
|
||||
</button>
|
||||
</div>
|
||||
{phoneError && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm mt-2 flex items-center gap-1">
|
||||
<span className="w-1 h-1 bg-red-500 dark:bg-red-400 rounded-full"></span>
|
||||
{phoneError}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-background/50 backdrop-blur-sm rounded-lg flex items-center justify-center">
|
||||
<Card className="w-80 shadow-2xl border-0 bg-white dark:bg-gray-800">
|
||||
<CardContent className="p-6 flex flex-col items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
|
||||
<span className="text-lg font-semibold text-primary-800 dark:text-primary-300">
|
||||
{t("common.verifying") || "Verifying..."}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground dark:text-gray-400 text-center text-sm">
|
||||
{t("otp.verifyingCode") ||
|
||||
"Please wait while we verify your code"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OTPModal;
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Lock, ArrowLeft, RefreshCw, Eye, EyeOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { setPassword } from "@/shared/services/authService";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const ResetPassword = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Get params from URL
|
||||
const email = searchParams.get("email") || "";
|
||||
const verificationCode = searchParams.get("verificationCode") || "";
|
||||
const userId = searchParams.get("userId") || "";
|
||||
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
// Check if we have all required parameters from the URL
|
||||
useEffect(() => {
|
||||
if (!userId && !email) {
|
||||
setError(
|
||||
"Missing required parameters in the reset link. Please request a new password reset link.",
|
||||
);
|
||||
} else if (!verificationCode) {
|
||||
setError(
|
||||
"Missing verification code in the reset link. Please request a new password reset link.",
|
||||
);
|
||||
} else {
|
||||
setError("");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// Validate passwords with specific error messages
|
||||
if (!newPassword) {
|
||||
setError(t("msg.newPasswordRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError(t("msg.passwordMinLength"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirmPassword) {
|
||||
setError(t("msg.confirmPasswordRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(t("msg.passwordMismatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for password complexity (optional but recommended)
|
||||
const hasUpperCase = /[A-Z]/.test(newPassword);
|
||||
const hasLowerCase = /[a-z]/.test(newPassword);
|
||||
const hasNumbers = /\d/.test(newPassword);
|
||||
const hasSpecialChar = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(newPassword);
|
||||
|
||||
const complexityErrors = [];
|
||||
if (!hasUpperCase) complexityErrors.push(t("msg.uppercaseLetter"));
|
||||
if (!hasLowerCase) complexityErrors.push(t("msg.lowercaseLetter"));
|
||||
if (!hasNumbers) complexityErrors.push(t("msg.number"));
|
||||
if (!hasSpecialChar) complexityErrors.push(t("msg.specialCharacter"));
|
||||
|
||||
if (complexityErrors.length > 0) {
|
||||
setError(t("msg.passwordComplexity") + " " + complexityErrors.join(", "));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const payload: {
|
||||
userId?: string;
|
||||
email?: string;
|
||||
verificationCode: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
} = {
|
||||
verificationCode,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
};
|
||||
|
||||
// Include both userId and email if available (API expects both)
|
||||
if (userId) {
|
||||
payload.userId = userId;
|
||||
}
|
||||
if (email) {
|
||||
payload.email = email;
|
||||
}
|
||||
|
||||
await setPassword(payload);
|
||||
|
||||
toast.success(t("msg.successChange"), {
|
||||
description: t("msg.passwordResetSuccess"),
|
||||
});
|
||||
|
||||
// Redirect to login page after successful reset
|
||||
setTimeout(() => navigate("/"), 2000);
|
||||
} catch (error: unknown) {
|
||||
const message = (error as any)?.message || t("msg.failedChange");
|
||||
setError(message);
|
||||
toast.error(t("msg.failedChange"), {
|
||||
description: message,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 p-4 flex items-center justify-center">
|
||||
<div className="w-full max-w-md bg-white rounded-xl shadow-lg p-6 md:p-8">
|
||||
<div className="mb-6 md:mb-8 text-center">
|
||||
<img
|
||||
src="/assets/smart-office-logo.svg"
|
||||
alt="Smart Office Logo"
|
||||
className="h-8 md:h-10 mb-4 md:mb-6 mx-auto"
|
||||
/>
|
||||
<h1 className="text-xl md:text-2xl font-bold text-gray-900 mb-2">
|
||||
Reset Password
|
||||
</h1>
|
||||
<p className="text-xs md:text-sm text-gray-500">
|
||||
Set a new password for your account
|
||||
</p>
|
||||
{email && (
|
||||
<p className="text-xs text-gray-400 mt-1 truncate max-w-full px-2">
|
||||
Email: {email}
|
||||
</p>
|
||||
)}
|
||||
{userId && (
|
||||
<p className="text-xs text-gray-400 mt-1 truncate max-w-full px-2">
|
||||
User ID: {userId}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 md:space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-4 w-4 md:h-5 md:w-5 text-gray-400" />
|
||||
</div>
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="New Password"
|
||||
className="h-10 rounded-md border px-4 text-sm ps-10 pr-10 text-black placeholder:text-gray-500"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-4 w-4 md:h-5 md:w-5 text-gray-400" />
|
||||
</div>
|
||||
<Input
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="Confirm New Password"
|
||||
className="h-10 rounded-md border px-4 text-sm ps-10 pr-10 text-black placeholder:text-gray-500"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
aria-label={
|
||||
showConfirmPassword ? "Hide password" : "Show password"
|
||||
}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-3 md:p-4">
|
||||
<p className="text-xs md:text-sm text-red-700 font-medium">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-10 bg-primary hover:bg-primary-300 text-white text-sm"
|
||||
disabled={loading || (!userId && !email) || !verificationCode}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<RefreshCw className="animate-spin h-4 w-4 md:h-5 md:w-5 mr-2" />
|
||||
Resetting...
|
||||
</span>
|
||||
) : (
|
||||
"Reset Password"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-10 text-sm"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Login
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPassword;
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/common/ui/dropdown-menu";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { getFormattedDashboardLabel } from "@/performance-management/utils/dashboardUtils";
|
||||
|
||||
interface DashboardDropdownProps {
|
||||
dashboards?: any[];
|
||||
selectedDashboard?: any;
|
||||
onSelect: (dashboard: any) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const DashboardDropdown: React.FC<DashboardDropdownProps> = ({
|
||||
dashboards = [],
|
||||
selectedDashboard,
|
||||
onSelect,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center justify-between w-60"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading
|
||||
? "Loading dashboards..."
|
||||
: getFormattedDashboardLabel(selectedDashboard?.name) ||
|
||||
"Select Dashboard"}
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-60">
|
||||
<DropdownMenuLabel>Available Dashboards</DropdownMenuLabel>
|
||||
{dashboards?.length > 0 ? (
|
||||
dashboards.map((dashboard: any) => (
|
||||
<DropdownMenuItem
|
||||
key={dashboard.id}
|
||||
onClick={() => onSelect(dashboard)}>
|
||||
{getFormattedDashboardLabel(dashboard.name)}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
) : (
|
||||
<DropdownMenuItem disabled>No dashboards found</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Breadcrumb, BreadcrumbList } from "@/shared/common/ui/breadcrumb";
|
||||
import { ChevronRight, Home } from "lucide-react";
|
||||
import { capitalize } from "@/shared/lib/utils";
|
||||
|
||||
interface BreadcrumbNode {
|
||||
label: string;
|
||||
path: string;
|
||||
isActive: boolean;
|
||||
isEllipsis?: boolean;
|
||||
}
|
||||
|
||||
const MODULE_LANDING_PATHS = new Set([
|
||||
"/record-management/dashboard",
|
||||
"/objective-management/plan-years",
|
||||
"/performance-management/plan-years",
|
||||
"/dms/dashboard",
|
||||
]);
|
||||
|
||||
// Module roots and plan-year list pages are reachable from the sidebar/module
|
||||
// switcher — omit them from breadcrumbs to avoid redundant navigation.
|
||||
const HIDDEN_BREADCRUMB_SEGMENTS = new Set([
|
||||
"plan-years",
|
||||
"record-management",
|
||||
"objective-management",
|
||||
"performance-management",
|
||||
"dms",
|
||||
"user-management",
|
||||
]);
|
||||
|
||||
const isDynamicId = (segment: string) =>
|
||||
/^[0-9a-f]{8,}$/i.test(segment) || /^[0-9]+$/.test(segment);
|
||||
|
||||
const makeReadableLabel = (segment: string) => {
|
||||
const withSpaces = segment
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/[_-]/g, " ");
|
||||
return capitalize(withSpaces);
|
||||
};
|
||||
|
||||
export function DynamicBreadcrumb() {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const breadcrumbs = useMemo(() => {
|
||||
if (MODULE_LANDING_PATHS.has(location.pathname)) {
|
||||
return [] as BreadcrumbNode[];
|
||||
}
|
||||
|
||||
const pathSegments = location.pathname.split("/").filter(Boolean);
|
||||
if (pathSegments.length === 0) return [] as BreadcrumbNode[];
|
||||
|
||||
const labelMap: Record<string, string> = {
|
||||
dashboard: t("header.navigation.dashboard"),
|
||||
userRecords: t("header.navigation.outgoing"),
|
||||
userIncoming: t("header.navigation.incoming"),
|
||||
delegation: t("header.navigation.delegation"),
|
||||
approval: t("header.navigation.approval"),
|
||||
pending: t("header.navigation.pending"),
|
||||
collaborations: t("header.navigation.collaborations"),
|
||||
recordOfficer: "Record Officer",
|
||||
incoming: t("header.navigation.incoming"),
|
||||
outgoing: t("header.navigation.outgoing"),
|
||||
add: t("common.create"),
|
||||
new: t("common.create"),
|
||||
edit: t("common.Edit"),
|
||||
profile: t("profile.profile"),
|
||||
"update-profile": t("profile.updateProfile"),
|
||||
"change-password": t("header.changePassword"),
|
||||
uploadTeeterandSignature: t("header.addTeeter"),
|
||||
viewDelegationLetter: t("delegation.viewLetter"),
|
||||
editDelegation: t("delegation.editDelegation"),
|
||||
incomingHistory: "Incoming History",
|
||||
incomingNonSmartExternal: "External Incoming",
|
||||
viewIncoming: "View Incoming",
|
||||
goals: t("organization.Goals", "Goals"),
|
||||
"detail-task": t("organization.detail_task", "Detail Task"),
|
||||
"Major-Task": t("organization.Major Task", "Major Task"),
|
||||
expectations: t("organization.expectations", "Expectations"),
|
||||
"employee-expectations": t(
|
||||
"organization.employeeExpectations",
|
||||
"Employee Expectations",
|
||||
),
|
||||
achievements: t("organization.achievements", "Achievements"),
|
||||
"additional-tasks": t("organization.additionalTasks", "Additional Tasks"),
|
||||
"signature-settings": t(
|
||||
"organization.Signature Settings",
|
||||
"Signature Settings",
|
||||
),
|
||||
};
|
||||
|
||||
let currentPath = "";
|
||||
const items: BreadcrumbNode[] = [];
|
||||
|
||||
for (const segment of pathSegments) {
|
||||
currentPath += `/${segment}`;
|
||||
|
||||
if (HIDDEN_BREADCRUMB_SEGMENTS.has(segment) || isDynamicId(segment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseLabel =
|
||||
labelMap[segment] || makeReadableLabel(segment);
|
||||
|
||||
items.push({
|
||||
label: baseLabel,
|
||||
path: currentPath,
|
||||
isActive: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length > 0) {
|
||||
items[items.length - 1].isActive = true;
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [location.pathname, t]);
|
||||
|
||||
if (!breadcrumbs.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const compactBreadcrumbs = breadcrumbs;
|
||||
|
||||
return (
|
||||
<div className="max-w-full overflow-hidden rounded-xl bg-white/80 px-2.5 py-1.5 shadow-sm ring-1 ring-black/5 backdrop-blur dark:bg-gray-800/70 dark:ring-white/10">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList className="flex-nowrap gap-0.5 overflow-x-auto whitespace-nowrap pb-0.5 text-xs">
|
||||
{compactBreadcrumbs.map((breadcrumb, index) => (
|
||||
<div key={`${breadcrumb.path}-${index}`} className="flex items-center">
|
||||
{index > 0 && (
|
||||
<span className="mx-1 text-gray-400 dark:text-gray-500">
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{breadcrumb.isEllipsis ? (
|
||||
<span className="px-2 py-1 text-xs font-semibold text-gray-400 dark:text-gray-500">
|
||||
...
|
||||
</span>
|
||||
) : breadcrumb.isActive ? (
|
||||
<span className="inline-flex max-w-[320px] items-center truncate rounded-full bg-primary-50 px-2 py-1 text-xs font-semibold text-primary-700 ring-1 ring-inset ring-primary-200/70 dark:bg-primary-900/40 dark:text-white dark:ring-primary-700/50" title={breadcrumb.label}>
|
||||
{breadcrumb.label}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
to={breadcrumb.path}
|
||||
className="inline-flex max-w-[240px] items-center gap-1 truncate rounded-full px-2 py-1 text-xs font-medium text-gray-500 transition-colors hover:bg-primary-50 hover:text-primary-700 dark:text-gray-300 dark:hover:bg-primary/10 dark:hover:text-primary-foreground"
|
||||
title={breadcrumb.label}
|
||||
>
|
||||
{index === 0 && <Home className="h-3.5 w-3.5" />}
|
||||
<span className="truncate">{breadcrumb.label}</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user