user management ui

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

View File

@@ -0,0 +1,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 />;
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -0,0 +1,9 @@
import React from 'react'
function ChangePassword() {
return (
<div>ChangePassword</div>
)
}
export default ChangePassword

View File

@@ -0,0 +1,9 @@
import React from 'react'
function Profile() {
return (
<div>Profile</div>
)
}
export default Profile

View File

@@ -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>
);
}

View File

@@ -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>
)
}

View File

@@ -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>
);
}

View File

@@ -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>
)
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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)}
/>
)}
</>
);
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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>
);
}