feat: implement dynamic theme support with useAppTheme hook and apply primary color across UI components

This commit is contained in:
Yashwin 2026-04-10 17:26:12 +05:30
parent 9647e3e632
commit fe85b8b5f6
31 changed files with 1583 additions and 638 deletions

View File

@ -20,7 +20,7 @@ import {
import { cn } from "@/lib/utils";
import { useAppSelector } from "@/hooks/redux-hooks";
import { useTenantTheme } from "@/hooks/useTenantTheme";
import { useAppTheme } from "@/hooks/useAppTheme";
import { AuthenticatedImage } from "@/components/shared";
interface MenuItem {
@ -109,6 +109,11 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [
path: "/tenant/files",
requiredPermission: { resource: "files" },
},
{
label: "Storage Dashboard",
path: "/tenant/files/storage-dashboard",
requiredPermission: { resource: "files" },
},
],
requiredPermission: { resource: "files" },
},
@ -185,15 +190,15 @@ const GroupMenuItem = ({
item,
childrenItems,
location,
isSuperAdmin,
theme,
primaryColor,
secondaryColor,
onClose,
}: {
item: MenuItem;
childrenItems: any[];
location: any;
isSuperAdmin: boolean;
theme: any;
primaryColor: string;
secondaryColor: string;
onClose: () => void;
}) => {
const isChildActive = (path: string) => {
@ -205,6 +210,14 @@ const GroupMenuItem = ({
);
if (isSubActionActive) return false;
}
// Special handling for Files List to NOT show as active when Storage Dashboard is active
if (path === "/tenant/files") {
if (location.pathname.startsWith("/tenant/files/storage-dashboard")) {
return false;
}
}
return (
location.pathname === path || location.pathname.startsWith(`${path}/`)
);
@ -233,14 +246,8 @@ const GroupMenuItem = ({
style={
isAnyChildActive
? {
backgroundColor:
!isSuperAdmin && theme?.primary_color
? theme.primary_color
: "#112868",
color:
!isSuperAdmin && theme?.secondary_color
? theme.secondary_color
: "#23dce1",
backgroundColor: primaryColor,
color: secondaryColor,
}
: undefined
}
@ -283,10 +290,7 @@ const GroupMenuItem = ({
style={
isActive
? {
color:
!isSuperAdmin && theme?.primary_color
? theme.primary_color
: "#112868",
color: primaryColor,
}
: undefined
}
@ -304,9 +308,9 @@ const GroupMenuItem = ({
};
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
const { primaryColor, secondaryColor, accentColor, logoUrl } = useAppTheme();
const location = useLocation();
const { roles, permissions } = useAppSelector((state) => state.auth);
const { theme, logoUrl } = useAppSelector((state) => state.theme);
// Fetch theme for tenant admin
const isSuperAdminCheck = () => {
@ -357,9 +361,9 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
const roleName = getRoleName();
// Fetch theme if tenant admin
if (!isSuperAdmin) {
useTenantTheme();
}
// if (!isSuperAdmin) {
// useTenantTheme();
// }
// Helper function to check if user has permission for a resource
const hasPermission = (
@ -461,8 +465,8 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
item={item}
childrenItems={children}
location={location}
isSuperAdmin={isSuperAdmin}
theme={theme}
primaryColor={primaryColor}
secondaryColor={secondaryColor}
onClose={onClose}
/>
);
@ -494,14 +498,8 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
style={
isActive
? {
backgroundColor:
!isSuperAdmin && theme?.primary_color
? theme.primary_color
: "#112868",
color:
!isSuperAdmin && theme?.secondary_color
? theme.secondary_color
: "#23dce1",
backgroundColor: primaryColor,
color: secondaryColor,
}
: undefined
}
@ -545,10 +543,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
className="w-9 h-9 rounded-[10px] flex items-center justify-center shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)] shrink-0"
style={{
display: !isSuperAdmin && logoUrl ? "none" : "flex",
backgroundColor:
!isSuperAdmin && theme?.primary_color
? theme.primary_color
: "#112868",
backgroundColor: primaryColor,
}}
>
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
@ -561,10 +556,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
<div
className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2"
style={{
color:
!isSuperAdmin && theme?.accent_color
? theme.accent_color
: "#084cc8",
color: accentColor
}}
>
{roleName}
@ -625,7 +617,10 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
alt="Logo"
className="h-9 w-auto max-w-[180px] object-contain"
fallback={
<div className="w-9 h-9 rounded-[10px] flex items-center justify-center shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)] shrink-0 bg-[#112868]">
<div
className="w-9 h-9 rounded-[10px] flex items-center justify-center shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)] shrink-0"
style={{ backgroundColor: primaryColor }}
>
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
</div>
}
@ -635,10 +630,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
className="w-9 h-9 rounded-[10px] flex items-center justify-center shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)] shrink-0"
style={{
display: !isSuperAdmin && logoUrl ? "none" : "flex",
backgroundColor:
!isSuperAdmin && theme?.primary_color
? theme.primary_color
: "#112868",
backgroundColor: primaryColor,
}}
>
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
@ -651,10 +643,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
<div
className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2"
style={{
color:
!isSuperAdmin && theme?.accent_color
? theme.accent_color
: "#084cc8",
color: accentColor
}}
>
{roleName}

View File

@ -1,8 +1,9 @@
import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import type { ReactElement } from 'react';
import { MoreVertical, Eye, Edit, Trash2, Users, BarChart3 } from 'lucide-react';
import { MoreVertical, Eye, Edit, Trash2, Users, BarChart3, Download } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppTheme } from '@/hooks/useAppTheme';
export interface ActionItem {
label: string;
@ -17,6 +18,7 @@ interface ActionDropdownProps {
onDelete?: () => void;
onContacts?: () => void;
onScorecards?: () => void;
onDownload?: () => void;
actions?: ActionItem[];
trigger?: React.ReactNode;
className?: string;
@ -28,10 +30,12 @@ export const ActionDropdown = ({
onDelete,
onContacts,
onScorecards,
onDownload,
actions,
trigger,
className,
}: ActionDropdownProps): ReactElement => {
const { primaryColor } = useAppTheme();
const [isOpen, setIsOpen] = useState<boolean>(false);
const [dropdownStyle, setDropdownStyle] = useState<{ top?: string; bottom?: string; right: string; width: string }>({ right: '0', width: '0' });
const dropdownRef = useRef<HTMLDivElement>(null);
@ -67,14 +71,22 @@ export const ActionDropdown = ({
const rect = buttonRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const dropdownHeight = actions ? actions.length * 32 + 16 : 120; // Approximate height based on actions
const dropdownHeight = (
(onView ? 1 : 0) +
(onEdit ? 1 : 0) +
(onDelete ? 1 : 0) +
(onContacts ? 1 : 0) +
(onScorecards ? 1 : 0) +
(onDownload ? 1 : 0) +
(actions ? actions.length : 0)
) * 36 + 12;
// Determine if should open upward or downward
const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
// Calculate dropdown position
const right = window.innerWidth - rect.right;
const width = actions ? 140 : 76; // Wider for custom actions
const width = (actions || onScorecards || onDownload || onContacts) ? 140 : 100; // Wider for longer labels
if (shouldOpenUp) {
// Position above the button
@ -125,12 +137,25 @@ export const ActionDropdown = ({
ref={buttonRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
'flex items-center justify-center w-7 h-7 rounded-full border border-[rgba(0,0,0,0.08)] transition-colors cursor-pointer',
isOpen
? 'bg-[#084cc8] text-white'
: 'bg-white text-[#0f1724] hover:bg-[#084cc8] hover:text-white'
)}
className="flex items-center justify-center w-7 h-7 rounded-full border border-[rgba(0,0,0,0.08)] transition-colors cursor-pointer"
style={isOpen
? { backgroundColor: primaryColor, color: 'white', borderColor: primaryColor }
: { backgroundColor: 'white', color: '#0f1724' }
}
onMouseEnter={(e) => {
if (!isOpen) {
e.currentTarget.style.backgroundColor = primaryColor;
e.currentTarget.style.color = 'white';
e.currentTarget.style.borderColor = primaryColor;
}
}}
onMouseLeave={(e) => {
if (!isOpen) {
e.currentTarget.style.backgroundColor = 'white';
e.currentTarget.style.color = '#0f1724';
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)';
}
}}
aria-label="Actions"
aria-expanded={isOpen}
>
@ -169,9 +194,9 @@ export const ActionDropdown = ({
<button
type="button"
onClick={() => handleAction(onView)}
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
className="flex items-center gap-2.5 px-3 py-2 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer w-full text-left"
>
<Eye className="w-3.5 h-3.5" />
<Eye className="w-3.5 h-3.5 shrink-0" />
<span>View</span>
</button>
)}
@ -179,9 +204,9 @@ export const ActionDropdown = ({
<button
type="button"
onClick={() => handleAction(onEdit)}
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
className="flex items-center gap-2.5 px-3 py-2 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer w-full text-left"
>
<Edit className="w-3 h-3" />
<Edit className="w-3.5 h-3.5 shrink-0" />
<span>Edit</span>
</button>
)}
@ -189,9 +214,9 @@ export const ActionDropdown = ({
<button
type="button"
onClick={() => handleAction(onDelete)}
className="flex items-center gap-2 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
className="flex items-center gap-2.5 px-3 py-2 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer w-full text-left"
>
<Trash2 className="w-3.5 h-3.5" />
<Trash2 className="w-3.5 h-3.5 shrink-0" />
<span>Delete</span>
</button>
)}
@ -199,9 +224,9 @@ export const ActionDropdown = ({
<button
type="button"
onClick={() => handleAction(onContacts)}
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
className="flex items-center gap-2.5 px-3 py-2 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer w-full text-left"
>
<Users className="w-3.5 h-3.5" />
<Users className="w-3.5 h-3.5 shrink-0" />
<span>Contacts</span>
</button>
)}
@ -209,12 +234,22 @@ export const ActionDropdown = ({
<button
type="button"
onClick={() => handleAction(onScorecards)}
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
className="flex items-center gap-2.5 px-3 py-2 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer w-full text-left"
>
<BarChart3 className="w-3.5 h-3.5" />
<BarChart3 className="w-3.5 h-3.5 shrink-0" />
<span>Scorecards</span>
</button>
)}
{onDownload && (
<button
type="button"
onClick={() => handleAction(onDownload)}
className="flex items-center gap-2.5 px-3 py-2 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer w-full text-left"
>
<Download className="w-3.5 h-3.5 shrink-0" />
<span>Download</span>
</button>
)}
</>
)}
</div>

View File

@ -12,6 +12,7 @@ interface DeleteConfirmationModalProps {
message: string;
itemName?: string;
isLoading?: boolean;
children?: React.ReactNode;
}
export const DeleteConfirmationModal = ({
@ -22,6 +23,7 @@ export const DeleteConfirmationModal = ({
message,
itemName,
isLoading = false,
children,
}: DeleteConfirmationModalProps): ReactElement | null => {
const modalRef = useRef<HTMLDivElement>(null);
@ -65,10 +67,10 @@ export const DeleteConfirmationModal = ({
if (!isOpen) return null;
const modalContent = (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-[rgba(15,23,42,0.6)] backdrop-blur-md p-4">
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-[rgba(15,23,42,0.6)] backdrop-blur-md p-4">
<div
ref={modalRef}
className="bg-white rounded-xl shadow-[0px_20px_25px_-5px_rgba(0,0,0,0.1),0px_10px_10px_-5px_rgba(0,0,0,0.04)] w-full max-w-[400px] z-[201]"
className="bg-white rounded-xl shadow-[0px_20px_25px_-5px_rgba(0,0,0,0.1),0px_10px_10px_-5px_rgba(0,0,0,0.04)] w-full max-w-[400px] z-[301]"
>
{/* Modal Header */}
<div className="flex items-start justify-between pb-4 pt-5 px-5 border-b border-[rgba(0,0,0,0.08)]">
@ -100,6 +102,7 @@ export const DeleteConfirmationModal = ({
)}
? This action cannot be undone.
</p>
{children && <div className="mt-4">{children}</div>}
</div>
{/* Modal Footer */}

View File

@ -13,6 +13,7 @@ import {
import { cn } from "@/lib/utils";
import { Modal } from "./Modal";
import fileAttachmentService, { type FileAttachment } from "@/services/file-attachment-service";
import { DeleteConfirmationModal } from "./DeleteConfirmationModal";
interface FileShareModalProps {
isOpen: boolean;
@ -30,7 +31,9 @@ export const FileShareModal: React.FC<FileShareModalProps> = ({
const [permissions, setPermissions] = useState<"view" | "download">("download");
const [isSharing, setIsSharing] = useState(false);
const [shareData, setShareData] = useState<{ url: string; token: string } | null>(null);
const [shareData, setShareData] = useState<{ url: string; token: string; id: string } | null>(null);
const [isRevoking, setIsRevoking] = useState(false);
const [showRevokeConfirm, setShowRevokeConfirm] = useState(false);
const [copied, setCopied] = useState(false);
const handleCreateShare = async () => {
@ -45,7 +48,7 @@ export const FileShareModal: React.FC<FileShareModalProps> = ({
});
const fullUrl = `${baseUrl}/files/shared/${res.data.share_token}`;
setShareData({ url: fullUrl, token: res.data.share_token });
setShareData({ url: fullUrl, token: res.data.share_token, id: res.data.id });
} catch (error) {
console.error("Failed to share:", error);
} finally {
@ -53,6 +56,22 @@ export const FileShareModal: React.FC<FileShareModalProps> = ({
}
};
const handleRevokeShare = async () => {
if (!shareData) return;
setIsRevoking(true);
try {
await fileAttachmentService.revokeShare(shareData.id);
setShareData(null);
setShowRevokeConfirm(false);
} catch (error) {
console.error("Failed to revoke share:", error);
alert("Failed to revoke share link. Please try again.");
} finally {
setIsRevoking(false);
}
};
const copyToClipboard = async () => {
if (!shareData) return;
try {
@ -90,6 +109,7 @@ export const FileShareModal: React.FC<FileShareModalProps> = ({
title="Share File"
description={file.original_name}
maxWidth="md"
preventCloseOnClickOutside={showRevokeConfirm}
>
<div className="p-6 space-y-6">
{!shareData ? (
@ -204,19 +224,33 @@ export const FileShareModal: React.FC<FileShareModalProps> = ({
</div>
</div>
<div className="pt-2 flex gap-2">
<div className="pt-2 flex flex-col gap-2">
<div className="flex gap-2">
<button
onClick={() => setShareData(null)}
className="flex-1 h-10 border border-[rgba(0,0,0,0.1)] hover:bg-gray-50 rounded-xl text-xs font-bold text-[#475569] transition-all"
>
Create Another
</button>
<button
onClick={() => window.open(shareData.url, '_blank')}
className="h-10 px-4 flex items-center gap-2 border border-[rgba(0,0,0,0.1)] hover:bg-gray-50 rounded-xl text-xs font-bold text-[#475569] transition-all"
>
<ExternalLink className="w-3.5 h-3.5" />
Test Link
</button>
</div>
<button
onClick={() => setShareData(null)}
className="flex-1 h-10 border border-[rgba(0,0,0,0.1)] hover:bg-gray-50 rounded-xl text-xs font-bold text-[#475569] transition-all"
onClick={() => setShowRevokeConfirm(true)}
disabled={isRevoking}
className="w-full h-10 border border-red-100 bg-red-50/50 hover:bg-red-50 rounded-xl text-xs font-bold text-red-600 transition-all flex items-center justify-center gap-2"
>
Create Another
</button>
<button
onClick={() => window.open(shareData.url, '_blank')}
className="h-10 px-4 flex items-center gap-2 border border-[rgba(0,0,0,0.1)] hover:bg-gray-50 rounded-xl text-xs font-bold text-[#475569] transition-all"
>
<ExternalLink className="w-3.5 h-3.5" />
Test Link
{isRevoking ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
"Revoke Link (Stop Sharing)"
)}
</button>
</div>
</div>
@ -229,6 +263,15 @@ export const FileShareModal: React.FC<FileShareModalProps> = ({
</p>
</div>
</div>
<DeleteConfirmationModal
isOpen={showRevokeConfirm}
onClose={() => setShowRevokeConfirm(false)}
onConfirm={handleRevokeShare}
title="Revoke Share Link"
message="Are you sure you want to revoke this share link? It will stop working immediately."
isLoading={isRevoking}
/>
</Modal>
);
};

View File

@ -14,7 +14,6 @@ import {
type ReactElement,
useEffect,
} from "react";
import { createPortal } from "react-dom";
import {
X,
Upload,
@ -29,6 +28,14 @@ import {
ChevronDown as ChevronDownIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
Modal,
PrimaryButton,
SecondaryButton,
FormField,
FormSelect,
FormTextArea,
} from "@/components/shared";
import fileAttachmentService, {
type CategoriesFilterOptions,
} from "@/services/file-attachment-service";
@ -328,348 +335,324 @@ export const FileUploadModal = ({
const validCount = files.filter((f) => f.status !== "blocked").length;
const doneCount = files.filter((f) => f.status === "done").length;
const content = (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-[rgba(15,23,42,0.55)] backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-[0px_25px_50px_-12px_rgba(0,0,0,0.25)] w-full max-w-[500px] max-h-[92vh] flex flex-col">
{/* Header */}
<div className="flex items-start justify-between px-6 pt-6 pb-4 border-b border-[rgba(0,0,0,0.08)] shrink-0">
<div>
<h2 className="text-[17px] font-semibold text-[#0e1b2a]">Upload New File</h2>
<p className="text-sm text-[#9aa6b2] mt-0.5">Attach files via File Attachment Service</p>
</div>
<button
onClick={handleClose}
disabled={isUploading}
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors text-[#6b7280] disabled:opacity-40"
>
<X className="w-4 h-4" />
</button>
</div>
const footer = (
<>
<SecondaryButton
onClick={handleClose}
disabled={isUploading}
>
Cancel
</SecondaryButton>
<PrimaryButton
onClick={handleUpload}
disabled={isUploading || files.length === 0 || validCount === 0}
className="flex items-center gap-2"
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Uploading ({doneCount}/{validCount})
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload {validCount > 0 ? `(${validCount})` : ""}
</>
)}
</PrimaryButton>
</>
);
{/* Scrollable body */}
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 space-y-5">
{/* Drop Zone */}
<div>
<p className="text-xs font-semibold text-[#0e1b2a] mb-2 uppercase tracking-wide">Attach Files</p>
{files.length === 0 ? (
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
className={cn(
"border-2 border-dashed rounded-xl flex flex-col items-center justify-center gap-3 py-8 cursor-pointer transition-all",
isDragging
? "border-[#084cc8] bg-[#084cc8]/5"
: "border-[rgba(0,0,0,0.12)] hover:border-[#084cc8]/50 hover:bg-gray-50"
)}
>
<div className="w-10 h-10 rounded-full bg-[#084cc8]/10 flex items-center justify-center">
<Upload className="w-5 h-5 text-[#084cc8]" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-[#0e1b2a]">Click to upload or drag and drop</p>
<p className="text-xs text-[#9aa6b2] mt-0.5">
Attach supporting source files via File Attachment Service
</p>
<p className="text-xs text-[#9aa6b2]">PDF, DOCX, XLSX up to {MAX_SIZE_MB}MB</p>
</div>
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Upload New File"
description="Attach files via File Attachment Service"
maxWidth="md"
footer={footer}
>
<div className="px-6 py-5 space-y-5">
{/* Drop Zone */}
<div>
<p className="text-xs font-semibold text-[#0e1b2a] mb-2 uppercase tracking-wide">Attach Files</p>
{files.length === 0 ? (
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
className={cn(
"border-2 border-dashed rounded-xl flex flex-col items-center justify-center gap-3 py-8 cursor-pointer transition-all",
isDragging
? "border-[#084cc8] bg-[#084cc8]/5"
: "border-[rgba(0,0,0,0.12)] hover:border-[#084cc8]/50 hover:bg-gray-50"
)}
>
<div className="w-10 h-10 rounded-full bg-[#084cc8]/10 flex items-center justify-center">
<Upload className="w-5 h-5 text-[#084cc8]" />
</div>
) : (
<div className="text-center text-gray-800">
<p className="text-sm font-medium text-[#0e1b2a]">Click to upload or drag and drop</p>
<p className="text-xs text-[#9aa6b2] mt-0.5">
Attach supporting source files via File Attachment Service
</p>
<p className="text-xs text-[#9aa6b2]">PDF, DOCX, XLSX up to {MAX_SIZE_MB}MB</p>
</div>
</div>
) : (
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={onDrop}
className={cn(
"border-2 border-dashed rounded-xl transition-all",
isDragging ? "border-[#084cc8] bg-[#084cc8]/5" : "border-[rgba(0,0,0,0.08)]"
)}
>
{/* Add files button */}
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={onDrop}
className={cn(
"border-2 border-dashed rounded-xl transition-all",
isDragging ? "border-[#084cc8] bg-[#084cc8]/5" : "border-[rgba(0,0,0,0.08)]"
)}
onClick={() => inputRef.current?.click()}
className="flex items-center justify-center gap-2 py-3 cursor-pointer border-b border-[rgba(0,0,0,0.06)] hover:bg-gray-50 transition-all rounded-t-xl"
>
{/* Add files button */}
<div
onClick={() => inputRef.current?.click()}
className="flex items-center justify-center gap-2 py-3 cursor-pointer border-b border-[rgba(0,0,0,0.06)] hover:bg-gray-50 transition-all rounded-t-xl"
>
<div className="w-7 h-7 rounded-full bg-[#084cc8] flex items-center justify-center">
<Upload className="w-3.5 h-3.5 text-white" />
</div>
<span className="text-sm font-semibold text-[#084cc8]">Add Files</span>
<div className="w-7 h-7 rounded-full bg-[#084cc8] flex items-center justify-center text-white">
<Upload className="w-3.5 h-3.5" />
</div>
<span className="text-sm font-semibold text-[#084cc8]">Add Files</span>
</div>
{/* File list */}
<div className="divide-y divide-[rgba(0,0,0,0.06)]">
{files.map((entry) => (
<div key={entry.id} className="flex items-center gap-3 px-4 py-2.5">
<div className="shrink-0">{getFileIcon(entry.file.type, entry.file.name)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[#0e1b2a] truncate max-w-[180px]">
{entry.file.name}
</span>
<span className="text-xs text-[#9aa6b2] shrink-0">
{formatBytes(entry.file.size)}
</span>
{entry.status === "uploading" && (
<span className="text-xs font-semibold text-[#084cc8] shrink-0">
{entry.progress}%
</span>
)}
{entry.status === "done" && (
<span className="text-xs font-semibold text-emerald-600 shrink-0 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Complete
</span>
)}
{(entry.status === "blocked" || entry.status === "error") && (
<span className="text-xs font-semibold text-red-500 shrink-0 flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> {entry.error}
</span>
)}
</div>
{/* File list */}
<div className="divide-y divide-[rgba(0,0,0,0.06)]">
{files.map((entry) => (
<div key={entry.id} className="flex items-center gap-3 px-4 py-2.5">
<div className="shrink-0">{getFileIcon(entry.file.type, entry.file.name)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[#0e1b2a] truncate max-w-[180px]">
{entry.file.name}
</span>
<span className="text-xs text-[#9aa6b2] shrink-0">
{formatBytes(entry.file.size)}
</span>
{entry.status === "uploading" && (
<div className="mt-1.5 h-1 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-[#084cc8] rounded-full transition-all duration-300"
style={{ width: `${entry.progress}%` }}
/>
</div>
<span className="text-xs font-semibold text-[#084cc8] shrink-0">
{entry.progress}%
</span>
)}
{entry.status === "done" && (
<div className="mt-1.5 h-1 bg-emerald-100 rounded-full overflow-hidden">
<div className="h-full bg-emerald-500 rounded-full w-full" />
</div>
<span className="text-xs font-semibold text-emerald-600 shrink-0 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Complete
</span>
)}
{(entry.status === "blocked" || entry.status === "error") && (
<div className="mt-1.5 h-1 bg-red-100 rounded-full" />
<span className="text-xs font-semibold text-red-500 shrink-0 flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> {entry.error}
</span>
)}
</div>
{entry.status !== "uploading" && entry.status !== "done" && (
<button
onClick={() => removeFile(entry.id)}
className="shrink-0 p-1 rounded hover:bg-gray-100 text-[#9aa6b2]"
>
<X className="w-3.5 h-3.5" />
</button>
{entry.status === "uploading" && (
<div className="mt-1.5 h-1 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-[#084cc8] rounded-full transition-all duration-300"
style={{ width: `${entry.progress}%` }}
/>
</div>
)}
{entry.status === "done" && (
<div className="mt-1.5 h-1 bg-emerald-100 rounded-full overflow-hidden">
<div className="h-full bg-emerald-500 rounded-full w-full" />
</div>
)}
{(entry.status === "blocked" || entry.status === "error") && (
<div className="mt-1.5 h-1 bg-red-100 rounded-full" />
)}
</div>
))}
</div>
</div>
)}
<p className="text-[11px] text-[#9aa6b2] mt-2">Up to {MAX_FILES} files allowed</p>
{uploadSuccess && (
<div className="flex items-center gap-1.5 mt-2 text-emerald-600">
<CheckCircle2 className="w-3.5 h-3.5" />
<span className="text-xs font-semibold">Files upload successfully.</span>
</div>
)}
</div>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={onInputChange}
/>
{/* Fields: Entity Type + Entity ID */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">
Entity Type <span className="text-red-500">*</span>
</label>
<select
value={entityType}
onChange={(e) => setEntityType(e.target.value)}
disabled={!!defaultEntityType || isUploading}
className="w-full h-9 border border-[rgba(0,0,0,0.12)] rounded-lg px-3 text-sm text-[#0e1b2a] bg-white focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8] disabled:bg-gray-50 disabled:text-[#9aa6b2]"
>
<option value="">Select type</option>
{ENTITY_TYPES.map((t) => (
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">
Entity ID <span className="text-red-500">*</span>
</label>
<div className="relative group/id">
<input
type="text"
value={entityId}
onChange={(e) => setEntityId(e.target.value)}
disabled={!!defaultEntityId || isUploading}
placeholder="e.g. PRJ-1204 (UUID)"
className="w-full h-9 border border-[rgba(0,0,0,0.12)] rounded-lg pl-3 pr-9 text-sm text-[#0e1b2a] placeholder:text-[#c4cbd6] focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8] disabled:bg-gray-50 disabled:text-[#9aa6b2]"
/>
{!defaultEntityId && !isUploading && isTenantAdmin && (
<button
onClick={() => setEntityId(generateUUID())}
title="Regenerate ID"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-[#9aa6b2] hover:text-[#084cc8] transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
</div>
{/* Category Name (Editable Combobox) */}
<div className="relative" ref={categoryDropdownRef}>
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">
Category Name <span className="text-[#9aa6b2] font-normal">(Select or Enter New)</span>
</label>
<div className="relative">
<input
type="text"
value={categoryInput}
onChange={(e) => {
setCategoryInput(e.target.value);
setCategorySearch(e.target.value);
setShowCategoryDropdown(true);
}}
onFocus={() => setShowCategoryDropdown(true)}
disabled={isUploading}
placeholder="Type or select a category..."
className="w-full h-9 border border-[rgba(0,0,0,0.12)] rounded-lg pl-3 pr-9 text-sm text-[#0e1b2a] bg-white focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8]"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-[#9aa6b2]">
{categoryInput && (
<button
onClick={() => { setCategoryInput(""); setCategorySearch(""); }}
className="p-1 hover:text-red-500"
>
<X className="w-3 h-3" />
</button>
)}
<ChevronDownIcon className="w-3.5 h-3.5" />
</div>
</div>
{showCategoryDropdown && !isUploading && (
<div className="absolute top-full left-0 right-0 z-[210] mt-1 bg-white border border-[rgba(0,0,0,0.1)] shadow-xl rounded-xl py-1 max-h-[200px] overflow-y-auto custom-scrollbar">
{categories.filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase())).length > 0 ? (
categories
.filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase()))
.map((cat) => (
{entry.status !== "uploading" && entry.status !== "done" && (
<button
key={cat.category_id ?? cat.category}
onClick={() => {
setCategoryInput(cat.category);
setShowCategoryDropdown(false);
}}
className="w-full text-left px-4 py-2 text-sm hover:bg-[#084cc8]/5 text-[#475569] hover:text-[#084cc8] transition-colors flex items-center justify-between"
onClick={() => removeFile(entry.id)}
className="shrink-0 p-1 rounded hover:bg-gray-100 text-[#9aa6b2]"
>
{cat.category}
{categoryInput === cat.category && <CheckCircle2 className="w-3.5 h-3.5" />}
<X className="w-3.5 h-3.5" />
</button>
))
) : (
<div className="px-4 py-2 text-xs text-[#9aa6b2] italic">
{categorySearch ? `Press enter to use "${categorySearch}"` : "No categories found"}
)}
</div>
)}
))}
</div>
)}
</div>
{/* Tags */}
<div>
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">Tags</label>
<div className="flex flex-wrap items-center gap-1.5 min-h-9 border border-[rgba(0,0,0,0.12)] rounded-lg px-2 py-1.5 focus-within:ring-2 focus-within:ring-[#084cc8]/20 focus-within:border-[#084cc8]">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 bg-gray-100 text-[#0e1b2a] text-xs font-medium rounded-md px-2 py-0.5"
>
{tag}
<button
onClick={() => removeTag(tag)}
disabled={isUploading}
className="text-[#9aa6b2] hover:text-red-500"
>
<X className="w-3 h-3" />
</button>
</span>
))}
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag();
}
}}
placeholder={tags.length === 0 ? "Add a tag..." : ""}
disabled={isUploading}
className="flex-1 min-w-[80px] text-sm outline-none bg-transparent placeholder:text-[#c4cbd6]"
/>
</div>
</div>
)}
<p className="text-[11px] text-[#9aa6b2] mt-2">Up to {MAX_FILES} files allowed</p>
{/* Description */}
<div>
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isUploading}
maxLength={500}
rows={3}
placeholder="Description of this file..."
className="w-full border border-[rgba(0,0,0,0.12)] rounded-lg px-3 py-2 text-sm text-[#0e1b2a] placeholder:text-[#c4cbd6] resize-none focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8]"
/>
</div>
{/* Upload error */}
{uploadError && (
<div className="flex items-center gap-2 rounded-lg bg-red-50 border border-red-100 px-3 py-2 text-sm text-red-600">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{uploadError}</span>
{uploadSuccess && (
<div className="flex items-center gap-1.5 mt-2 text-emerald-600">
<CheckCircle2 className="w-3.5 h-3.5" />
<span className="text-xs font-semibold">Files upload successfully.</span>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[rgba(0,0,0,0.08)] shrink-0">
<button
onClick={handleClose}
disabled={isUploading}
className="h-9 px-4 border border-[rgba(0,0,0,0.12)] rounded-lg text-sm font-medium text-[#0e1b2a] hover:bg-gray-50 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleUpload}
disabled={isUploading || files.length === 0 || validCount === 0}
className="h-9 px-4 bg-[#112868] hover:bg-[#0c1e52] text-white rounded-lg text-sm font-semibold flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Uploading ({doneCount}/{validCount})
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload {validCount > 0 ? `(${validCount})` : ""}
</>
)}
</button>
</div>
</div>
</div>
);
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={onInputChange}
/>
return createPortal(content, document.body);
{/* Fields: Entity Type + Entity ID */}
<div className="grid grid-cols-2 gap-4 pb-0">
<FormSelect
label="Entity Type"
required
value={entityType}
onValueChange={setEntityType}
disabled={!!defaultEntityType || isUploading}
options={ENTITY_TYPES.map((t) => ({
value: t,
label: t.charAt(0).toUpperCase() + t.slice(1),
}))}
placeholder="Select type"
/>
<div className="relative">
<FormField
label="Entity ID"
required
value={entityId}
onChange={(e) => setEntityId(e.target.value)}
disabled={!!defaultEntityId || isUploading}
placeholder="e.g. PRJ-1204"
/>
{!defaultEntityId && !isUploading && isTenantAdmin && (
<button
onClick={() => setEntityId(generateUUID())}
title="Regenerate ID"
className="absolute right-3 top-[38px] p-1 text-[#9aa6b2] hover:text-[#084cc8] transition-colors"
type="button"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Category Name (Editable Combobox) */}
<div className="relative pb-4" ref={categoryDropdownRef}>
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">
Category Name <span className="text-[#9aa6b2] font-normal">(Select or Enter New)</span>
</label>
<div className="relative">
<input
type="text"
value={categoryInput}
onChange={(e) => {
setCategoryInput(e.target.value);
setCategorySearch(e.target.value);
setShowCategoryDropdown(true);
}}
onFocus={() => setShowCategoryDropdown(true)}
disabled={isUploading}
placeholder="Type or select a category..."
className="w-full h-10 border border-[rgba(0,0,0,0.12)] rounded-lg pl-3 pr-9 text-sm text-[#0e1b2a] bg-white focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8]"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-[#9aa6b2]">
{categoryInput && (
<button
type="button"
onClick={() => { setCategoryInput(""); setCategorySearch(""); }}
className="p-1 hover:text-red-500"
>
<X className="w-3 h-3" />
</button>
)}
<ChevronDownIcon className="w-3.5 h-3.5" />
</div>
</div>
{showCategoryDropdown && !isUploading && (
<div className="absolute top-full left-0 right-0 z-[210] mt-1 bg-white border border-[rgba(0,0,0,0.1)] shadow-xl rounded-xl py-1 max-h-[200px] overflow-y-auto custom-scrollbar">
{categories.filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase())).length > 0 ? (
categories
.filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase()))
.map((cat) => (
<button
key={cat.category_id ?? cat.category}
type="button"
onClick={() => {
setCategoryInput(cat.category);
setShowCategoryDropdown(false);
}}
className="w-full text-left px-4 py-2 text-sm hover:bg-[#084cc8]/5 text-[#475569] hover:text-[#084cc8] transition-colors flex items-center justify-between"
>
{cat.category}
{categoryInput === cat.category && <CheckCircle2 className="w-3.5 h-3.5" />}
</button>
))
) : (
<div className="px-4 py-2 text-xs text-[#9aa6b2] italic">
{categorySearch ? `Press enter to use "${categorySearch}"` : "No categories found"}
</div>
)}
</div>
)}
</div>
{/* Tags */}
<div className="pb-4">
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">Tags</label>
<div className="flex flex-wrap items-center gap-1.5 min-h-10 border border-[rgba(0,0,0,0.12)] rounded-lg px-2 py-1.5 focus-within:ring-2 focus-within:ring-[#084cc8]/20 focus-within:border-[#084cc8]">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 bg-gray-100 text-[#0e1b2a] text-xs font-medium rounded-md px-2 py-0.5"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
disabled={isUploading}
className="text-[#9aa6b2] hover:text-red-500"
>
<X className="w-3 h-3" />
</button>
</span>
))}
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag();
}
}}
placeholder={tags.length === 0 ? "Add a tag..." : ""}
disabled={isUploading}
className="flex-1 min-w-[80px] text-sm outline-none bg-transparent placeholder:text-[#c4cbd6]"
/>
</div>
</div>
{/* Description */}
<FormTextArea
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isUploading}
maxLength={500}
rows={3}
placeholder="Description of this file..."
/>
{/* Upload error */}
{uploadError && (
<div className="flex items-center gap-2 rounded-lg bg-red-50 border border-red-100 px-3 py-2 text-sm text-red-600">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{uploadError}</span>
</div>
)}
</div>
</Modal>
);
};
export default FileUploadModal;

View File

@ -0,0 +1,251 @@
/**
* FileVersionUploadModal
* Specifically for uploading a new version of an existing file attachment.
* Maps to POST /api/v1/files/:id/versions
*/
import {
useCallback,
useRef,
useState,
type ChangeEvent,
type DragEvent,
type ReactElement,
} from "react";
import {
X,
Upload,
CheckCircle2,
Loader2,
AlertCircle,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
Modal,
PrimaryButton,
SecondaryButton,
FormTextArea,
} from "@/components/shared";
import fileAttachmentService, {
type FileAttachment,
} from "@/services/file-attachment-service";
interface FileVersionUploadModalProps {
isOpen: boolean;
onClose: () => void;
file: FileAttachment;
onUploaded?: (newFile: FileAttachment) => void;
}
export const FileVersionUploadModal = ({
isOpen,
onClose,
file,
onUploaded,
}: FileVersionUploadModalProps): ReactElement | null => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [description, setDescription] = useState("");
const [tagInput, setTagInput] = useState("");
const [tags, setTags] = useState<string[]>(file.tags || []);
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleClose = () => {
if (isUploading) return;
setSelectedFile(null);
setDescription("");
setTags(file.tags || []);
setTagInput("");
setUploadError(null);
onClose();
};
const onDrop = useCallback(
(e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
const dropped = e.dataTransfer.files[0];
if (dropped) setSelectedFile(dropped);
},
[]
);
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.[0]) setSelectedFile(e.target.files[0]);
e.target.value = "";
};
const handleUpload = async () => {
if (!selectedFile) return;
setIsUploading(true);
setUploadError(null);
try {
const res = await fileAttachmentService.uploadVersion(file.id, selectedFile, {
description: description.trim() || undefined,
tags: tags.length ? tags : undefined,
category: file.category,
});
onUploaded?.(res.data);
handleClose();
} catch (err: any) {
const msg = err?.response?.data?.error?.message || err?.message || "Version upload failed";
setUploadError(msg);
} finally {
setIsUploading(false);
}
};
if (!isOpen) return null;
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Upload New Version"
description={`Creating version ${file.version + 1} for ${file.original_name}`}
maxWidth="md"
footer={
<>
<SecondaryButton onClick={handleClose} disabled={isUploading}>
Cancel
</SecondaryButton>
<PrimaryButton
onClick={handleUpload}
disabled={isUploading || !selectedFile}
className="flex items-center gap-2"
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Uploading...
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload Version
</>
)}
</PrimaryButton>
</>
}
>
<div className="px-6 py-5 space-y-5">
<div>
<p className="text-xs font-semibold text-[#0e1b2a] mb-2 uppercase tracking-wide">Select New File</p>
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
className={cn(
"border-2 border-dashed rounded-xl flex flex-col items-center justify-center gap-3 py-8 cursor-pointer transition-all",
isDragging
? "border-[#084cc8] bg-[#084cc8]/5"
: "border-[rgba(0,0,0,0.12)] hover:border-[#084cc8]/50 hover:bg-gray-50",
selectedFile && "border-emerald-500/50 bg-emerald-50/10"
)}
>
{selectedFile ? (
<>
<div className="w-10 h-10 rounded-full bg-emerald-500/10 flex items-center justify-center">
<CheckCircle2 className="w-5 h-5 text-emerald-600" />
</div>
<div className="text-center">
<p className="text-sm font-bold text-[#0e1b2a] px-4 truncate max-w-[300px]">
{selectedFile.name}
</p>
<p className="text-xs text-[#9aa6b2] mt-0.5">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
<button className="text-xs font-semibold text-[#084cc8] mt-2 underline">
Change file
</button>
</div>
</>
) : (
<>
<div className="w-10 h-10 rounded-full bg-[#084cc8]/10 flex items-center justify-center">
<Upload className="w-5 h-5 text-[#084cc8]" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-[#0e1b2a]">Click to upload or drag and drop</p>
<p className="text-xs text-[#9aa6b2] mt-0.5">
Supported: PDF, DOCX, XLSX, Images, etc.
</p>
</div>
</>
)}
</div>
<input
ref={inputRef}
type="file"
className="hidden"
onChange={onInputChange}
/>
</div>
<div>
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">Tags</label>
<div className="flex flex-wrap items-center gap-1.5 min-h-10 border border-[rgba(0,0,0,0.12)] rounded-lg px-2 py-1.5 focus-within:ring-2 focus-within:ring-[#084cc8]/20 focus-within:border-[#084cc8]">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 bg-gray-100 text-[#0e1b2a] text-xs font-medium rounded-md px-2 py-0.5"
>
{tag}
<button
type="button"
onClick={() => setTags((prev) => prev.filter((x) => x !== tag))}
disabled={isUploading}
className="text-[#9aa6b2] hover:text-red-500"
>
<X className="w-3 h-3" />
</button>
</span>
))}
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
const t = tagInput.trim();
if (t && !tags.includes(t)) setTags((prev) => [...prev, t]);
setTagInput("");
}
}}
placeholder={tags.length === 0 ? "Add a tag..." : ""}
disabled={isUploading}
className="flex-1 min-w-[80px] text-sm outline-none bg-transparent"
/>
</div>
</div>
<FormTextArea
label="Version Notes / Changes"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isUploading}
maxLength={500}
rows={3}
placeholder="What's new in this version?"
/>
{uploadError && (
<div className="flex items-center gap-2 rounded-lg bg-red-50 border border-red-100 px-3 py-2 text-sm text-red-600">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{uploadError}</span>
</div>
)}
</div>
</Modal>
);
};

View File

@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import type { ReactElement } from 'react';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppTheme } from '@/hooks/useAppTheme';
interface FilterOption {
value: string | string[];
@ -30,6 +31,7 @@ export const FilterDropdown = ({
icon,
isSearchable = false,
}: FilterDropdownProps): ReactElement => {
const { primaryColor } = useAppTheme();
const [isOpen, setIsOpen] = useState<boolean>(false);
const [localSearch, setLocalSearch] = useState<string>('');
const dropdownRef = useRef<HTMLDivElement>(null);
@ -133,13 +135,17 @@ export const FilterDropdown = ({
onClick={() => setIsOpen(!isOpen)}
className={cn(
"flex items-center gap-2 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-sm transition-all min-h-[40px] hover:bg-gray-50",
value ? "border-[#112868]/20 bg-[#112868]/5" : "text-[#475569]"
!value && "text-[#475569]"
)}
style={value ? { borderColor: `${primaryColor}33`, backgroundColor: `${primaryColor}0D` } : {}}
>
{showIcon && icon && <span className="flex-shrink-0 text-[#94a3b8]">{icon}</span>}
<span className={cn("font-medium", value && "text-[#112868]")}>{label}</span>
<span className="font-medium" style={value ? { color: primaryColor } : {}}>{label}</span>
{value && (
<span className="text-[#112868] font-bold text-xs bg-white px-1.5 py-0.5 rounded border border-[#112868]/10 ml-0.5">
<span
className="font-bold text-xs bg-white px-1.5 py-0.5 rounded border ml-0.5"
style={{ color: primaryColor, borderColor: `${primaryColor}1A` }}
>
{displayText}
</span>
)}
@ -164,7 +170,19 @@ export const FilterDropdown = ({
placeholder="Search..."
value={localSearch}
onChange={(e) => setLocalSearch(e.target.value)}
className="w-full px-2 py-1.5 text-xs border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 focus:ring-[#112868]/20 bg-gray-50/50"
className="w-full px-2 py-1.5 text-xs border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 bg-gray-50/50"
style={{
// @ts-ignore
'--tw-ring-color': `${primaryColor}33`
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = primaryColor;
e.currentTarget.style.boxShadow = `0 0 0 1px ${primaryColor}4D`;
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)';
e.currentTarget.style.boxShadow = 'none';
}}
autoFocus
/>
</div>

View File

@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
import type { ReactElement } from 'react';
import { cn } from '@/lib/utils';
import { useAppTheme } from '@/hooks/useAppTheme';
interface LimitOption {
value: string;
@ -36,6 +37,7 @@ export const Pagination = ({
onLimitChange,
limitOptions = defaultLimitOptions,
}: PaginationProps): ReactElement => {
const { primaryColor } = useAppTheme();
const [isLimitOpen, setIsLimitOpen] = useState<boolean>(false);
const limitDropdownRef = useRef<HTMLDivElement>(null);
const limitButtonRef = useRef<HTMLButtonElement>(null);
@ -189,7 +191,18 @@ export const Pagination = ({
type="button"
onClick={handleNext}
disabled={currentPage >= totalPages}
className="flex items-center gap-1 px-3 py-1.5 bg-[#112868] text-white rounded text-xs hover:bg-[#0e1f5a] transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] min-w-[44px]"
className="flex items-center gap-1 px-3 py-1.5 text-white rounded text-xs transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] min-w-[44px]"
style={currentPage < totalPages ? { backgroundColor: primaryColor } : { backgroundColor: '#112868', opacity: 0.5 }}
onMouseEnter={(e) => {
if (currentPage < totalPages) {
e.currentTarget.style.filter = 'brightness(1.1)';
}
}}
onMouseLeave={(e) => {
if (currentPage < totalPages) {
e.currentTarget.style.filter = 'none';
}
}}
>
<span className="hidden sm:inline">Next</span>
<ChevronRight className="w-3.5 h-3.5" />

View File

@ -1,7 +1,7 @@
import type { ReactElement, ButtonHTMLAttributes } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { useAppSelector } from '@/hooks/redux-hooks';
import { useAppTheme } from '@/hooks/useAppTheme';
const primaryButtonVariants = cva(
'inline-flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer',
@ -39,31 +39,7 @@ export const PrimaryButton = ({
...props
}: PrimaryButtonProps): ReactElement => {
const buttonVariant = disabled ? 'disabled' : variant || 'default';
const { theme } = useAppSelector((state) => state.theme);
const { roles } = useAppSelector((state) => state.auth);
// Check if user is tenant admin (not super_admin) or if we're on a tenant route
let rolesArray: string[] = [];
if (Array.isArray(roles)) {
rolesArray = roles;
} else if (typeof roles === 'string') {
try {
rolesArray = JSON.parse(roles);
} catch {
rolesArray = [];
}
}
const isSuperAdmin = rolesArray.includes('super_admin');
const isTenantAdmin = !isSuperAdmin && rolesArray.length > 0;
// Check if we're on a tenant route (for login page where user might not be authenticated)
// Use /tenant/ or exactly /tenant to avoid matching /tenants page
const isTenantRoute = typeof window !== 'undefined' && (window.location.pathname.startsWith('/tenant/') || window.location.pathname === '/tenant');
// Use theme colors for tenant admin or tenant routes, default colors for super admin
const shouldUseTheme = (isTenantAdmin || (isTenantRoute && !isSuperAdmin)) && theme;
const primaryColor = shouldUseTheme && theme.primary_color ? theme.primary_color : '#112868';
const secondaryColor = shouldUseTheme && theme.secondary_color ? theme.secondary_color : '#23dce1';
const { primaryColor, secondaryColor } = useAppTheme();
return (
<button

View File

@ -1,7 +1,7 @@
import type { ReactElement, ButtonHTMLAttributes } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { useAppSelector } from '@/hooks/redux-hooks';
import { useAppTheme } from '@/hooks/useAppTheme';
const secondaryButtonVariants = cva(
'inline-flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer',
@ -32,31 +32,7 @@ export const SecondaryButton = ({
...props
}: SecondaryButtonProps): ReactElement => {
const buttonVariant = disabled ? 'disabled' : variant || 'default';
const { theme } = useAppSelector((state) => state.theme);
const { roles } = useAppSelector((state) => state.auth);
// Check if user is tenant admin (not super_admin) or if we're on a tenant route
let rolesArray: string[] = [];
if (Array.isArray(roles)) {
rolesArray = roles;
} else if (typeof roles === 'string') {
try {
rolesArray = JSON.parse(roles);
} catch {
rolesArray = [];
}
}
const isSuperAdmin = rolesArray.includes('super_admin');
const isTenantAdmin = !isSuperAdmin && rolesArray.length > 0;
// Check if we're on a tenant route (for login page where user might not be authenticated)
// Use /tenant/ or exactly /tenant to avoid matching /tenants page
const isTenantRoute = typeof window !== 'undefined' && (window.location.pathname.startsWith('/tenant/') || window.location.pathname === '/tenant');
// Use theme colors for tenant admin or tenant routes, default colors for super admin
const shouldUseTheme = (isTenantAdmin || (isTenantRoute && !isSuperAdmin)) && theme;
const primaryColor = shouldUseTheme && theme.primary_color ? theme.primary_color : '#112868';
const secondaryColor = shouldUseTheme && theme.secondary_color ? theme.secondary_color : '#23dce1';
const { primaryColor, secondaryColor } = useAppTheme();
return (
<button

View File

@ -1,6 +1,7 @@
import type { ReactElement } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { useAppTheme } from '@/hooks/useAppTheme';
const statusBadgeVariants = cva(
'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-semibold uppercase',
@ -36,9 +37,18 @@ export const StatusBadge = ({
variant = 'success',
className,
}: StatusBadgeProps): ReactElement => {
const { primaryColor } = useAppTheme();
const isInfo = variant === 'info';
return (
<div className={cn(statusBadgeVariants({ variant }), className)}>
<div className={cn('rounded size-1.5 shrink-0', variant && statusDotColors[variant])} />
<div
className={cn(statusBadgeVariants({ variant }), className)}
style={isInfo ? { backgroundColor: `${primaryColor}1A`, color: primaryColor } : {}}
>
<div
className={cn('rounded size-1.5 shrink-0', variant && statusDotColors[variant])}
style={isInfo ? { backgroundColor: primaryColor } : {}}
/>
<span>{children}</span>
</div>
);

View File

@ -16,6 +16,7 @@ import { Plus, Building2 } from "lucide-react";
import { supplierService } from "@/services/supplier-service";
import type { Supplier } from "@/types/supplier";
import { formatDate } from "@/utils/format-date";
import { useAppTheme } from "@/hooks/useAppTheme";
interface SuppliersTableProps {
tenantId?: string | null;
@ -59,6 +60,7 @@ export const SuppliersTable = ({
showHeader = true,
compact = false,
}: SuppliersTableProps): ReactElement => {
const { primaryColor } = useAppTheme();
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
@ -288,7 +290,20 @@ export const SuppliersTable = ({
<input
type="text"
placeholder="Search suppliers..."
className="pl-3 pr-10 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-xs focus:outline-none focus:ring-1 focus:ring-[#112868] w-full sm:w-64"
className="pl-3 pr-10 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-xs focus:outline-none focus:ring-2 w-full sm:w-64 transition-all"
style={{
// @ts-ignore
'--tw-ring-color': `${primaryColor}33`,
borderColor: 'rgba(0,0,0,0.08)'
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = primaryColor;
e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}1A`;
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)';
e.currentTarget.style.boxShadow = 'none';
}}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);

View File

@ -24,6 +24,7 @@ import type {
} from "@/types/department";
import { showToast } from "@/utils/toast";
import type { RootState } from "@/store/store";
import { useAppTheme } from "@/hooks/useAppTheme";
interface DepartmentsTableProps {
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
@ -36,6 +37,7 @@ const DepartmentsTable = ({
compact = false,
showHeader = true,
}: DepartmentsTableProps): ReactElement => {
const { primaryColor } = useAppTheme();
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
const effectiveTenantId = propsTenantId || reduxTenantId;
@ -257,7 +259,20 @@ const DepartmentsTable = ({
<input
type="text"
placeholder="Search departments..."
className="w-full pl-9 pr-4 py-2 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-[#0052cc]"
className="w-full pl-9 pr-4 py-2 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-2 transition-all"
style={{
// @ts-ignore
'--tw-ring-color': `${primaryColor}33`,
borderColor: 'rgba(0,0,0,0.08)'
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = primaryColor;
e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}1A`;
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)';
e.currentTarget.style.boxShadow = 'none';
}}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>

View File

@ -24,6 +24,7 @@ import type {
} from "@/types/designation";
import { showToast } from "@/utils/toast";
import type { RootState } from "@/store/store";
import { useAppTheme } from "@/hooks/useAppTheme";
interface DesignationsTableProps {
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
@ -36,6 +37,7 @@ const DesignationsTable = ({
compact = false,
showHeader = true,
}: DesignationsTableProps): ReactElement => {
const { primaryColor } = useAppTheme();
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
const effectiveTenantId = propsTenantId || reduxTenantId;
@ -248,7 +250,20 @@ const DesignationsTable = ({
<input
type="text"
placeholder="Search designations..."
className="w-full pl-9 pr-4 py-2 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-[#0052cc]"
className="w-full pl-9 pr-4 py-2 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-2 transition-all"
style={{
// @ts-ignore
'--tw-ring-color': `${primaryColor}33`,
borderColor: 'rgba(0,0,0,0.08)'
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = primaryColor;
e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}1A`;
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)';
e.currentTarget.style.boxShadow = 'none';
}}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>

View File

@ -2,8 +2,10 @@ import { useNavigate } from 'react-router-dom';
import { Plus, UserPlus, Shield, Settings, Building2, BadgeCheck } from 'lucide-react';
import { useAppSelector } from '@/hooks/redux-hooks';
import type { QuickAction } from '@/types/dashboard';
import { useAppTheme } from '@/hooks/useAppTheme';
export const QuickActions = () => {
const { primaryColor } = useAppTheme();
const navigate = useNavigate();
const { roles, permissions } = useAppSelector((state) => state.auth);
@ -50,7 +52,7 @@ export const QuickActions = () => {
className="bg-white border-2 border-dashed border-[#f1f5f9] hover:border-[#cbd5e1] hover:bg-gray-50 flex flex-col items-center justify-center p-4 min-h-[100px] rounded-xl transition-all gap-3 group"
>
<div className="w-8 h-8 rounded-full bg-gray-50 border border-gray-100 flex items-center justify-center group-hover:bg-white transition-colors overflow-hidden">
<Icon className="w-4 h-4 text-[#084cc8]" strokeWidth={2} />
<Icon className="w-4 h-4" color={primaryColor} strokeWidth={2} />
</div>
<span className="text-[11px] font-bold text-[#111827] text-center leading-none">
{action.label}

View File

@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
import { auditLogService } from '@/services/audit-log-service';
import type { AuditLog } from '@/types/audit-log';
import { useAppSelector } from '@/hooks/redux-hooks';
import { useAppTheme } from '@/hooks/useAppTheme';
import { cn } from '@/lib/utils';
import { Card, CardHeader, CardContent } from '@/components/ui/card';
import { StatusBadge } from '@/components/shared';
@ -46,6 +47,7 @@ export interface RecentActivityProps {
}
export const RecentActivity = ({ variant }: RecentActivityProps) => {
const { primaryColor } = useAppTheme();
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const { tenantId } = useAppSelector((state) => state.auth);
@ -80,7 +82,8 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
<Button
variant="ghost"
size="sm"
className="text-[11px] font-bold text-[#084cc8] hover:bg-[#084cc8]/5 gap-1 h-7"
className="text-[11px] font-bold gap-1 h-7"
style={{ color: primaryColor }}
onClick={() => navigate('/tenant/audit-logs')}
>
View All <ArrowRight className="w-3 h-3" />
@ -90,7 +93,7 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
<div className="flex-1">
{isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-[#084cc8] animate-spin" />
<Loader2 className="w-6 h-6 animate-spin" style={{ color: primaryColor }} />
</div>
) : auditLogs.length === 0 ? (
<div className="p-12 text-center text-[12px] text-gray-400 font-medium">No recent activity</div>
@ -104,8 +107,13 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[12px] font-bold text-[#111827]">{log.action}</span>
<span className="text-[12px] font-bold text-[#084cc8] hover:underline cursor-pointer truncate">{log.resource_type}</span>
<span className="text-[12px] font-bold">{log.action}</span>
<span
className="text-[12px] font-bold hover:underline cursor-pointer truncate"
style={{ color: primaryColor }}
>
{log.resource_type}
</span>
</div>
</div>
</div>
@ -155,7 +163,12 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
<span className="text-[12px] font-medium text-gray-500">{formatRelativeTime(log.created_at)}</span>
</td>
<td className="px-5 py-4 whitespace-nowrap">
<span className="text-[12px] font-bold text-[#084cc8] truncate max-w-[150px] inline-block">{log.resource_type}</span>
<span
className="text-[12px] font-bold truncate max-w-[150px] inline-block"
style={{ color: primaryColor }}
>
{log.resource_type}
</span>
</td>
<td className="px-5 py-4 whitespace-nowrap">
<StatusBadge variant={getMethodVariant(log.request_method)}>

54
src/hooks/useAppTheme.ts Normal file
View File

@ -0,0 +1,54 @@
import { useAppSelector } from '@/hooks/redux-hooks';
/**
* Hook to get the current applied theme colors and configuration.
* Automatically handles super_admin vs tenant role logic and route checks.
*/
export const useAppTheme = () => {
const { theme, logoUrl: stateLogoUrl } = useAppSelector((state) => state.theme);
const { roles } = useAppSelector((state) => state.auth);
// Default platform colors
const DEFAULT_PRIMARY = '#112868';
const DEFAULT_SECONDARY = '#23dce1';
const DEFAULT_ACCENT = '#084cc8';
// Process roles safely
let rolesArray: string[] = [];
if (Array.isArray(roles)) {
rolesArray = roles;
} else if (typeof roles === 'string') {
try {
rolesArray = JSON.parse(roles);
} catch {
rolesArray = [];
}
}
const isSuperAdmin = rolesArray.includes('super_admin');
const isTenantAdmin = !isSuperAdmin && rolesArray.length > 0;
// Check if we're on a tenant route
const isTenantRoute = typeof window !== 'undefined' &&
(window.location.pathname.startsWith('/tenant/') || window.location.pathname === '/tenant');
// Logic to determine if theme should be applied
const shouldUseTheme = (isTenantAdmin || (isTenantRoute && !isSuperAdmin)) && !!theme;
const primaryColor = shouldUseTheme && theme?.primary_color ? theme.primary_color : DEFAULT_PRIMARY;
const secondaryColor = shouldUseTheme && theme?.secondary_color ? theme.secondary_color : DEFAULT_SECONDARY;
const accentColor = shouldUseTheme && theme?.accent_color ? theme.accent_color : DEFAULT_ACCENT;
const logoUrl = shouldUseTheme && stateLogoUrl ? stateLogoUrl : null;
return {
theme,
primaryColor,
secondaryColor,
accentColor,
logoUrl,
shouldUseTheme,
isSuperAdmin,
isTenantAdmin,
isTenantRoute
};
};

View File

@ -13,6 +13,8 @@ import { Download, ArrowUpDown, Search } from 'lucide-react';
import { auditLogService } from '@/services/audit-log-service';
import { moduleService } from '@/services/module-service';
import type { AuditLog } from '@/types/audit-log';
import { useAppTheme } from '@/hooks/useAppTheme';
import { PrimaryButton } from '@/components/shared';
import { useAppSelector } from '@/hooks/redux-hooks';
// Helper function to format date
@ -58,6 +60,7 @@ const getStatusColor = (status: number | null): string => {
};
const AuditLogs = (): ReactElement => {
const { primaryColor } = useAppTheme();
const roles = useAppSelector((state) => state.auth.roles);
const tenantId = useAppSelector((state) => state.auth.tenantId);
const isTenantAdmin = roles?.includes('tenant_admin');
@ -309,7 +312,8 @@ const AuditLogs = (): ReactElement => {
<button
type="button"
onClick={() => handleViewAuditLog(log.id)}
className="text-sm text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors"
className="text-sm font-medium transition-colors hover:opacity-80"
style={{ color: primaryColor }}
>
View
</button>
@ -333,7 +337,8 @@ const AuditLogs = (): ReactElement => {
<button
type="button"
onClick={() => handleViewAuditLog(log.id)}
className="text-sm text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors shrink-0"
className="text-sm font-medium transition-colors shrink-0 hover:opacity-80"
style={{ color: primaryColor }}
>
View
</button>
@ -396,7 +401,15 @@ const AuditLogs = (): ReactElement => {
placeholder="Search logs & metadata..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[#112868]/10 focus:border-[#112868] transition-all bg-gray-50/30"
className="w-full pl-9 pr-4 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-2 transition-all bg-gray-50/30"
onFocus={(e) => {
e.currentTarget.style.borderColor = primaryColor;
e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}1A`;
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</div>
@ -472,14 +485,14 @@ const AuditLogs = (): ReactElement => {
{/* Actions */}
<div className="flex items-center gap-2">
{isTenantAdmin && (
<button
type="button"
<PrimaryButton
onClick={handleExport}
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
size="small"
className="flex items-center gap-1.5"
>
<Download className="w-3.5 h-3.5" />
<span>Export</span>
</button>
</PrimaryButton>
)}
</div>
</div>
@ -522,7 +535,8 @@ const AuditLogs = (): ReactElement => {
setSearch('');
setCurrentPage(1);
}}
className="text-xs text-[#ef4444] hover:underline"
className="text-xs hover:underline decoration-offset-2"
style={{ color: primaryColor }}
>
Clear all filters
</button>

View File

@ -187,10 +187,10 @@ const CreateDocument = (): ReactElement => {
return (
<Layout
currentPage="Document Service"
breadcrumbs={[
{ label: "Document Service", path: "/tenant/documents" },
{ label: "Create Document" },
]}
// breadcrumbs={[
// { label: "Document Service", path: "/tenant/documents" },
// { label: "Create Document" },
// ]}
pageHeader={{
title: "Create Document",
description:

View File

@ -11,6 +11,7 @@ import { QuickActions } from "@/features/dashboard/components/QuickActions";
import { RecentActivity } from "@/features/dashboard/components/RecentActivity";
import { cn } from "@/lib/utils";
import { useState, useEffect } from "react";
import { useAppTheme } from "@/hooks/useAppTheme";
import { workflowService } from "@/services/workflow-service";
import { dashboardService, type TenantDashboardStats } from "@/services/dashboard-service";
import type { WorkflowTask } from "@/types/workflow";
@ -32,11 +33,15 @@ const StatCard = ({
label,
badge
}: StatCardProps): ReactElement => {
const { primaryColor } = useAppTheme();
return (
<div className="relative group h-full">
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4.5 flex flex-col gap-4 shadow-sm hover:shadow-md transition-all h-full relative overflow-hidden">
{/* Interaction Gradient */}
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-[#084cc8] via-[#75c044] to-[#fed314] opacity-0 group-hover:opacity-100 transition-opacity" />
<div
className="absolute top-0 left-0 w-full h-[2px] opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: `linear-gradient(to right, ${primaryColor}, #75c044, #fed314)` }}
/>
<div className="flex items-start justify-between">
<div className="w-10 h-10 rounded-md bg-gray-50 flex items-center justify-center border border-gray-100">
@ -69,6 +74,7 @@ const StatCard = ({
};
const TaskCard = ({ task }: { task: WorkflowTask }) => {
const { primaryColor } = useAppTheme();
const navigate = useNavigate();
const formatDeadline = (dueDate: string) => {
const now = new Date();
@ -118,7 +124,10 @@ const TaskCard = ({ task }: { task: WorkflowTask }) => {
>
View
</button>
<button className="text-[11px] px-2.5 py-1.5 bg-[#084cc8] text-white rounded-md font-bold hover:bg-[#063ba1] transition-colors shadow-sm shadow-[#084cc8]/20 shrink-0">
<button
className="text-[11px] px-2.5 py-1.5 text-white rounded-md font-bold transition-colors shadow-sm"
style={{ backgroundColor: primaryColor, boxShadow: `0 2px 4px ${primaryColor}33` }}
>
Complete
</button>
</div>
@ -190,6 +199,7 @@ const CAPASummaryChart = () => {
*/
const Dashboard = (): ReactElement => {
const { primaryColor } = useAppTheme();
const navigate = useNavigate();
const [tasks, setTasks] = useState<WorkflowTask[]>([]);
const [stats, setStats] = useState<TenantDashboardStats | null>(null);
@ -336,7 +346,8 @@ const Dashboard = (): ReactElement => {
<h2 className="text-[16px] font-bold text-[#111827] tracking-tight">My Tasks</h2>
<button
onClick={() => navigate('/tenant/workflows/tasks')}
className="text-[11px] font-bold text-[#084cc8] hover:underline"
className="text-[11px] font-bold hover:underline"
style={{ color: primaryColor }}
>
View all
</button>

View File

@ -12,6 +12,7 @@ import { documentService } from "@/services/document-service";
import { moduleService } from "@/services/module-service";
import type { DocumentCategory, DocumentSummary } from "@/types/document";
import type { Module } from "@/types/module";
import { useAppTheme } from "@/hooks/useAppTheme";
import { Plus, Search } from "lucide-react";
const formatDate = (value?: string | null): string => {
@ -30,6 +31,7 @@ const toLabel = (value: string): string =>
.join(" ");
const Documents = (): ReactElement => {
const { primaryColor } = useAppTheme();
const navigate = useNavigate();
const [documents, setDocuments] = useState<DocumentSummary[]>([]);
const [categories, setCategories] = useState<DocumentCategory[]>([]);
@ -109,7 +111,8 @@ const Documents = (): ReactElement => {
render: (doc) => (
<button
type="button"
className="text-[#084cc8] hover:underline"
className="hover:underline transition-colors"
style={{ color: primaryColor }}
onClick={() => navigate(`/tenant/documents/${doc.id}`)}
>
{doc.document_number}
@ -137,7 +140,10 @@ const Documents = (): ReactElement => {
key: "status",
label: "Status",
render: (doc) => (
<span className="inline-flex items-center rounded-md bg-[#112868]/10 px-2 py-1 text-[11px] text-[#112868]">
<span
className="inline-flex items-center rounded-md px-2 py-1 text-[11px]"
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
>
{toLabel(doc.status)}
</span>
),
@ -169,7 +175,8 @@ const Documents = (): ReactElement => {
render: (doc) => (
<button
type="button"
className="text-xs text-[#084cc8] hover:underline font-medium"
className="text-xs transition-colors hover:underline font-medium"
style={{ color: primaryColor }}
onClick={(e) => {
e.stopPropagation();
navigate(`/tenant/documents/edit/${doc.id}`);
@ -225,7 +232,20 @@ const Documents = (): ReactElement => {
setCurrentPage(1);
}}
placeholder="Search by name, ID..."
className="h-10 w-full pl-9 pr-3 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-[#112868]/10 transition-all"
className="h-10 w-full pl-9 pr-3 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-2 transition-all"
style={{
// @ts-ignore
'--tw-ring-color': `${primaryColor}33`,
borderColor: 'rgba(0,0,0,0.08)'
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = primaryColor;
e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}1A`;
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</div>

View File

@ -26,9 +26,11 @@ import {
RefreshCw,
ZoomIn,
ZoomOut,
Plus,
} from "lucide-react";
import { Layout } from "@/components/layout/Layout";
import { FileShareModal } from "@/components/shared";
import { FileShareModal } from "@/components/shared/FileShareModal";
import { FileVersionUploadModal } from "@/components/shared/FileVersionUploadModal";
import { cn } from "@/lib/utils";
import fileAttachmentService, {
type FileAttachment,
@ -78,19 +80,53 @@ function copyToClipboard(text: string): void {
// Preview component
// ─────────────────────────────────────────────────────────────────────────────
function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(false);
const [zoom, setZoom] = useState(1);
const [extractedHtml, setExtractedHtml] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
setErr(false);
fileAttachmentService
.getPreviewUrl(file.id)
.then((url) => setPreviewUrl(url))
.catch(() => setErr(true))
.finally(() => setLoading(false));
const loadPreview = async () => {
setLoading(true);
setErr(false);
setExtractedHtml(null);
setPreviewUrl(undefined);
const isOffice = [
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.ms-powerpoint",
].includes(file.mime_type || "");
try {
if (isOffice) {
try {
const res = await fileAttachmentService.extractContent(file.id);
if (res.success && (res.data.html || res.data.text)) {
setExtractedHtml(res.data.html || res.data.text);
setLoading(false);
return;
}
} catch (extractionErr) {
console.warn("Content extraction failed, falling back to blob preview", extractionErr);
}
}
const url = await fileAttachmentService.getPreviewUrl(file.id);
setPreviewUrl(url);
} catch (error) {
console.error("Preview load failed", error);
setErr(true);
} finally {
setLoading(false);
}
};
void loadPreview();
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
@ -99,7 +135,6 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
}, [file.id]);
const isImage = file.mime_type?.startsWith("image/");
const isPdf = file.mime_type === "application/pdf";
if (loading) {
return (
@ -109,12 +144,18 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
);
}
if (err || !previewUrl) {
if (err || (!previewUrl && !extractedHtml)) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-[#9aa6b2]">
<FileText className="w-16 h-16 text-gray-200" />
<p className="text-sm">Preview not available</p>
<p className="text-xs">{file.mime_type}</p>
<p className="text-xs text-center px-4">{file.mime_type}</p>
<button
onClick={() => fileAttachmentService.download(file.id, file.original_name).catch(() => {})}
className="mt-2 h-8 px-4 bg-white border border-[rgba(0,0,0,0.12)] rounded-lg text-xs font-semibold text-[#0e1b2a] hover:bg-gray-50 transition-colors shadow-sm"
>
Download to View
</button>
</div>
);
}
@ -154,17 +195,30 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
className="max-w-full rounded-lg shadow transition-transform duration-200"
/>
</div>
) : isPdf || file.mime_type?.startsWith("text/") ? (
) : previewUrl ? (
<iframe
src={previewUrl}
title={file.original_name}
className="flex-1 w-full border-0"
className="flex-1 w-full border-0 bg-white"
/>
) : extractedHtml ? (
<div className="flex-1 overflow-auto bg-white p-8">
<div
className="max-w-4xl mx-auto prose prose-sm prose-slate bg-white shadow-sm border border-gray-100 p-8 rounded-lg"
dangerouslySetInnerHTML={{ __html: extractedHtml }}
/>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-[#9aa6b2] bg-gray-50">
<FileText className="w-16 h-16 text-gray-200" />
<p className="text-sm">No visual preview available</p>
<p className="text-sm">Preview not available</p>
<p className="text-xs text-[#c4cbd6]">{file.mime_type}</p>
<button
onClick={() => fileAttachmentService.download(file.id, file.original_name).catch(() => {})}
className="mt-2 h-8 px-4 bg-white border border-[rgba(0,0,0,0.12)] rounded-lg text-xs font-semibold text-[#0e1b2a] hover:bg-gray-50 transition-colors shadow-sm"
>
Download to View
</button>
</div>
)}
</div>
@ -233,7 +287,8 @@ const FileView = (): ReactElement => {
const [editingMetadata, setEditingMetadata] = useState(false);
const [copiedChecksum, setCopiedChecksum] = useState(false);
const [copiedPath, setCopiedPath] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
// Metadata edit form
const [draftDescription, setDraftDescription] = useState("");
@ -352,7 +407,14 @@ const FileView = (): ReactElement => {
<div className="flex items-center gap-2">
<button
onClick={() => setShowShareModal(true)}
onClick={() => setIsVersionModalOpen(true)}
className="inline-flex items-center gap-2 h-9 px-3 border border-[rgba(0,0,0,0.1)] rounded-lg text-sm font-medium text-[#475569] hover:bg-gray-50 hover:border-[#084cc8]/30 transition-all font-semibold"
>
<Plus className="w-3.5 h-3.5" />
New Version
</button>
<button
onClick={() => setIsShareModalOpen(true)}
className="inline-flex items-center gap-2 h-9 px-3 border border-[rgba(0,0,0,0.1)] rounded-lg text-sm font-medium text-[#475569] hover:bg-gray-50 hover:border-[#084cc8]/30 transition-all font-semibold"
>
<Share2 className="w-3.5 h-3.5" />
@ -603,11 +665,24 @@ const FileView = (): ReactElement => {
</div>
</div>
<FileShareModal
isOpen={showShareModal}
onClose={() => setShowShareModal(false)}
file={file}
/>
{isShareModalOpen && file && (
<FileShareModal
isOpen={isShareModalOpen}
onClose={() => setIsShareModalOpen(false)}
file={file}
/>
)}
{isVersionModalOpen && file && (
<FileVersionUploadModal
isOpen={isVersionModalOpen}
onClose={() => setIsVersionModalOpen(false)}
file={file}
onUploaded={(newFile) => {
navigate(`/tenant/files/${newFile.id}`);
}}
/>
)}
</Layout>
);
};

View File

@ -19,23 +19,21 @@ import {
Image,
FileArchive,
Table as TableIcon,
MoreHorizontal,
Download,
Eye,
Pencil,
Trash2,
ChevronDown,
} from "lucide-react";
import { Layout } from "@/components/layout/Layout";
import { Pagination } from "@/components/shared";
import { DeleteConfirmationModal } from "@/components/shared/DeleteConfirmationModal";
import { cn } from "@/lib/utils";
import fileAttachmentService, {
type FileAttachment,
type CategoriesFilterOptions,
} from "@/services/file-attachment-service";
import { moduleService } from "@/services/module-service";
import { FilterDropdown } from "@/components/shared";
import { FilterDropdown, ActionDropdown } from "@/components/shared";
import { FileUploadModal } from "@/components/shared/FileUploadModal";
import { useAppTheme } from "@/hooks/useAppTheme";
import { PrimaryButton } from "@/components/shared";
import type { RootState } from "@/store/store";
// ─────────────────────────────────────────────────────────────────────────────
@ -60,7 +58,7 @@ function formatBytes(bytes: number): string {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
function getFileIcon(mime: string, name: string): ReactElement {
function getFileIcon(mime: string, name: string, primaryColor: string): ReactElement {
if (mime?.startsWith("image/")) return <Image className="w-4 h-4 text-emerald-500" />;
if (mime === "application/pdf") return <FileText className="w-4 h-4 text-red-500" />;
if (
@ -72,7 +70,7 @@ function getFileIcon(mime: string, name: string): ReactElement {
return <TableIcon className="w-4 h-4 text-green-600" />;
if (mime?.includes("zip") || mime?.includes("archive"))
return <FileArchive className="w-4 h-4 text-yellow-500" />;
return <FileText className="w-4 h-4 text-[#084cc8]" />;
return <FileText className="w-4 h-4" style={{ color: primaryColor }} />;
}
const categoryColors: Record<string, string> = {
@ -142,6 +140,7 @@ function FilterPill({
}): ReactElement {
const [open, setOpen] = useState(false);
const selected = options.find((o) => o.value === value);
const { primaryColor } = useAppTheme();
return (
<div className="relative">
@ -150,12 +149,13 @@ function FilterPill({
className={cn(
"inline-flex items-center gap-1.5 h-9 px-3 rounded-lg text-sm font-medium border transition-colors",
value
? "border-[#084cc8] bg-[#084cc8]/5 text-[#084cc8]"
: "border-[rgba(0,0,0,0.1)] bg-white text-[#475569] hover:border-[#084cc8]/30"
? "bg-opacity-5"
: "border-[rgba(0,0,0,0.1)] bg-white text-[#475569]"
)}
style={value ? { borderColor: primaryColor, backgroundColor: `${primaryColor}10`, color: primaryColor } : {}}
>
{label}
{selected && <span className="text-[#084cc8]">: {selected.label}</span>}
{selected && <span style={{ color: primaryColor }}>: {selected.label}</span>}
<ChevronDown className="w-3.5 h-3.5 opacity-60" />
</button>
{open && (
@ -193,78 +193,12 @@ function FilterPill({
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Row action menu
// ─────────────────────────────────────────────────────────────────────────────
function ActionMenu({
onView,
onDownload,
onEdit,
onDelete,
canEdit,
canDelete,
}: {
onView: () => void;
onDownload: () => void;
onEdit: () => void;
onDelete: () => void;
canEdit: boolean;
canDelete: boolean;
}): ReactElement {
const [open, setOpen] = useState(false);
return (
<div className="relative">
<button
onClick={() => setOpen(!open)}
className="w-7 h-7 flex items-center justify-center rounded-lg hover:bg-gray-100 text-[#6b7280] transition-colors"
>
<MoreHorizontal className="w-4 h-4" />
</button>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute right-0 top-full mt-1 z-20 bg-white rounded-xl border border-[rgba(0,0,0,0.08)] shadow-lg py-1 min-w-[140px]">
<button
onClick={() => { onView(); setOpen(false); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[#0e1b2a] hover:bg-gray-50"
>
<Eye className="w-3.5 h-3.5 text-[#6b7280]" /> View
</button>
<button
onClick={() => { onDownload(); setOpen(false); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[#0e1b2a] hover:bg-gray-50"
>
<Download className="w-3.5 h-3.5 text-[#6b7280]" /> Download
</button>
{canEdit && (
<button
onClick={() => { onEdit(); setOpen(false); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[#0e1b2a] hover:bg-gray-50"
>
<Pencil className="w-3.5 h-3.5 text-[#6b7280]" /> Edit
</button>
)}
{canDelete && (
<button
onClick={() => { onDelete(); setOpen(false); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-500 hover:bg-red-50"
>
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
)}
</div>
</>
)}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Main component
// ─────────────────────────────────────────────────────────────────────────────
const FilesList = (): ReactElement => {
const navigate = useNavigate();
const { primaryColor } = useAppTheme();
const permissions = useSelector((state: RootState) => state.auth.permissions);
// Permission checks
@ -292,7 +226,7 @@ const FilesList = (): ReactElement => {
// Pagination
const [currentPage, setCurrentPage] = useState(1);
const [limit] = useState(10);
const [limit, setLimit] = useState(10);
const offset = (currentPage - 1) * limit;
const totalPages = Math.max(1, Math.ceil(total / limit));
@ -303,7 +237,9 @@ const FilesList = (): ReactElement => {
const [showUpload, setShowUpload] = useState(false);
// Deleting
const [, setDeletingId] = useState<string | null>(null);
const [fileToDelete, setFileToDelete] = useState<{ id: string; name: string } | null>(null);
const [isHardDelete, setIsHardDelete] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// ── Load categories ──
useEffect(() => {
@ -352,19 +288,26 @@ const FilesList = (): ReactElement => {
fileAttachmentService.download(file.id, file.original_name).catch(() => {});
};
const handleDelete = async (id: string) => {
if (!window.confirm("Delete this file?")) return;
setDeletingId(id);
const handleDelete = async () => {
if (!fileToDelete) return;
setIsDeleting(true);
try {
await fileAttachmentService.delete(id);
await fileAttachmentService.delete(fileToDelete.id, isHardDelete);
setFileToDelete(null);
setIsHardDelete(false);
await loadFiles();
} catch {
// silence
} catch (err: any) {
alert(err?.response?.data?.error?.message || "Failed to delete file");
} finally {
setDeletingId(null);
setIsDeleting(false);
}
};
const openDeleteConfirm = (file: FileAttachment) => {
setFileToDelete({ id: file.id, name: file.original_name });
setIsHardDelete(false);
};
const clearFilters = () => {
setSearch("");
setCategoryFilter(null);
@ -378,22 +321,22 @@ const FilesList = (): ReactElement => {
return (
<Layout
currentPage="File Attachment Services"
breadcrumbs={[
{ label: "File Attachment Services" },
{ label: "File List" },
]}
// breadcrumbs={[
// { label: "File Attachment Services" },
// { label: "File List" },
// ]}
pageHeader={{
title: "Files List",
description: "Manage controlled documents across their entire lifecycle.",
action: canCreate ? (
<button
<PrimaryButton
id="upload-new-file-btn"
onClick={() => setShowUpload(true)}
className="inline-flex items-center gap-2 h-9 px-4 bg-[#112868] hover:bg-[#0c1e52] text-white rounded-lg text-sm font-semibold transition-colors shadow-sm"
className="flex items-center gap-2"
>
<Upload className="w-3.5 h-3.5" />
Upload New File
</button>
</PrimaryButton>
) : null,
}}
>
@ -410,7 +353,20 @@ const FilesList = (): ReactElement => {
value={search}
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
placeholder="Search by name, ID..."
className="h-9 w-[240px] pl-9 pr-3 bg-white border border-[rgba(0,0,0,0.1)] rounded-lg text-sm text-[#0e1b2a] placeholder:text-[#c4cbd6] focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8]"
className="h-9 w-[240px] pl-9 pr-3 bg-white border border-[rgba(0,0,0,0.1)] rounded-lg text-sm text-[#0e1b2a] placeholder:text-[#c4cbd6] focus:outline-none focus:ring-2"
style={{
// @ts-ignore
'--tw-ring-color': `${primaryColor}33`,
borderColor: 'rgba(0,0,0,0.1)'
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = primaryColor;
e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}33`;
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.1)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</div>
@ -493,7 +449,8 @@ const FilesList = (): ReactElement => {
{canCreate && (
<button
onClick={() => setShowUpload(true)}
className="mt-2 text-sm font-medium text-[#084cc8] hover:underline"
className="mt-2 text-sm font-medium hover:underline"
style={{ color: primaryColor }}
>
Upload your first file
</button>
@ -511,12 +468,16 @@ const FilesList = (): ReactElement => {
<td className="px-4 py-3 min-w-[200px]">
<button
onClick={() => navigate(`/tenant/files/${file.id}`)}
className="flex items-center gap-2.5 hover:text-[#084cc8] transition-colors text-left"
className="flex items-center gap-2.5 transition-colors text-left group/link"
>
<div className="w-7 h-7 rounded-md bg-gray-50 border border-[rgba(0,0,0,0.06)] flex items-center justify-center shrink-0">
{getFileIcon(file.mime_type, file.original_name)}
{getFileIcon(file.mime_type, file.original_name, primaryColor)}
</div>
<span className="text-sm font-medium text-[#0e1b2a] group-hover:text-[#084cc8] truncate max-w-[200px]">
<span
className="text-sm font-medium text-[#0e1b2a] truncate max-w-[200px]"
onMouseEnter={(e) => e.currentTarget.style.color = primaryColor}
onMouseLeave={(e) => e.currentTarget.style.color = '#0e1b2a'}
>
{file.original_name}
</span>
</button>
@ -594,13 +555,11 @@ const FilesList = (): ReactElement => {
{/* Actions */}
<td className="px-4 py-3">
<ActionMenu
<ActionDropdown
onView={() => navigate(`/tenant/files/${file.id}`)}
onDownload={() => handleDownload(file)}
onEdit={() => navigate(`/tenant/files/${file.id}`)}
onDelete={() => handleDelete(file.id)}
canEdit={canUpdate}
canDelete={canDelete}
onEdit={canUpdate ? () => navigate(`/tenant/files/${file.id}`) : undefined}
onDelete={canDelete ? () => openDeleteConfirm(file) : undefined}
/>
</td>
</tr>
@ -618,7 +577,7 @@ const FilesList = (): ReactElement => {
totalItems={total}
limit={limit}
onPageChange={setCurrentPage}
onLimitChange={() => {}}
onLimitChange={setLimit}
/>
)}
</div>
@ -634,6 +593,34 @@ const FilesList = (): ReactElement => {
}}
isTenantAdmin={canCreate}
/>
{fileToDelete && (
<DeleteConfirmationModal
isOpen={!!fileToDelete}
onClose={() => setFileToDelete(null)}
onConfirm={handleDelete}
title="Delete File"
message="Are you sure you want to delete this file"
itemName={fileToDelete.name}
isLoading={isDeleting}
>
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-100 rounded-lg">
<input
type="checkbox"
id="hard-delete-check"
checked={isHardDelete}
onChange={(e) => setIsHardDelete(e.target.checked)}
className="w-4 h-4 text-red-600 focus:ring-red-500 border-red-300 rounded"
/>
<label htmlFor="hard-delete-check" className="text-sm font-semibold text-red-700 cursor-pointer">
Permanent Delete (Hard Delete)
</label>
<p className="text-[10px] text-red-600/70 ml-1">
{isHardDelete ? "Files will be wiped from storage." : "Files will be moved to trash."}
</p>
</div>
</DeleteConfirmationModal>
)}
</Layout>
);
};

View File

@ -9,6 +9,7 @@ import {
import { useAppSelector } from '@/hooks/redux-hooks';
import { moduleService } from '@/services/module-service';
import type { MyModule } from '@/types/module';
import { useAppTheme } from '@/hooks/useAppTheme';
// Helper function to get status badge variant
const getStatusVariant = (status: string | null): 'success' | 'failure' | 'process' => {
@ -26,6 +27,7 @@ const getStatusVariant = (status: string | null): 'success' | 'failure' | 'proce
};
const Modules = (): ReactElement => {
const { primaryColor } = useAppTheme();
const { roles, tenantId } = useAppSelector((state) => state.auth);
const [modules, setModules] = useState<MyModule[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
@ -131,7 +133,8 @@ const Modules = (): ReactElement => {
<button
type="button"
onClick={() => handleLaunchModule(module.id)}
className="text-sm text-[#FFC107] hover:text-[#FFC107] font-medium transition-colors cursor-pointer"
className="text-sm font-medium transition-colors cursor-pointer hover:opacity-80"
style={{ color: primaryColor }}
>
Launch
</button>
@ -172,7 +175,8 @@ const Modules = (): ReactElement => {
<button
type="button"
onClick={() => handleLaunchModule(module.id)}
className="text-sm text-[#FFC107] hover:text-[#FFC107] font-medium transition-colors"
className="text-sm font-medium transition-colors hover:opacity-80"
style={{ color: primaryColor }}
>
Launch
</button>

View File

@ -0,0 +1,378 @@
import { useEffect, useState, type ReactElement } from "react";
import {
Database,
ShieldCheck,
Building2,
Package,
AlertCircle,
Pencil,
HardDrive,
Files,
FileText,
Image as ImageIcon,
CheckCircle2,
Save,
Loader2,
} from "lucide-react";
import { Layout } from "@/components/layout/Layout";
import { cn } from "@/lib/utils";
import fileAttachmentService, {
type StorageStats,
type StorageQuota,
} from "@/services/file-attachment-service";
import {
Modal,
FormField,
PrimaryButton,
SecondaryButton,
} from "@/components/shared";
import { useAppTheme } from "@/hooks/useAppTheme";
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
function formatBytes(bytes: number | string): string {
const b = typeof bytes === "string" ? parseInt(bytes) : bytes;
if (!b || b === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(b) / Math.log(k));
return `${parseFloat((b / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
// ─────────────────────────────────────────────────────────────────────────────
// Components
// ─────────────────────────────────────────────────────────────────────────────
interface QuotaEditModalProps {
isOpen: boolean;
onClose: () => void;
quota: StorageQuota;
onUpdated: () => void;
}
const QuotaEditModal = ({ isOpen, onClose, quota, onUpdated }: QuotaEditModalProps) => {
const [maxStorageMB, setMaxStorageMB] = useState(
Math.floor((typeof quota.max_storage_bytes === 'string' ? parseInt(quota.max_storage_bytes) : quota.max_storage_bytes) / 1024 / 1024)
);
const [maxFileMB, setMaxFileMB] = useState(
Math.floor(quota.max_file_size_bytes / 1024 / 1024)
);
const [isUpdating, setIsUpdating] = useState(false);
const handleSubmit = async () => {
setIsUpdating(true);
try {
await fileAttachmentService.updateQuota({
max_storage_bytes: maxStorageMB * 1024 * 1024,
max_file_size_bytes: maxFileMB * 1024 * 1024,
});
onUpdated();
onClose();
} catch (err) {
alert("Failed to update quota");
} finally {
setIsUpdating(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Edit Storage Quota"
maxWidth="sm"
footer={
<>
{/* <SecondaryButton onClick={onClose} disabled={isUpdating}>Cancel</SecondaryButton> */}
<PrimaryButton onClick={handleSubmit} disabled={isUpdating} className="flex items-center gap-2">
{isUpdating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save Changes
</PrimaryButton>
</>
}
>
<div className="p-6 space-y-5">
<FormField
label="Max Total Storage (MB)"
type="number"
value={maxStorageMB}
onChange={(e) => setMaxStorageMB(parseInt(e.target.value) || 0)}
placeholder="e.g. 10240 for 10GB"
/>
<FormField
label="Max Per-File Size (MB)"
type="number"
value={maxFileMB}
onChange={(e) => setMaxFileMB(parseInt(e.target.value) || 0)}
placeholder="e.g. 50 for 50MB"
/>
</div>
</Modal>
);
};
const StorageDashboard = (): ReactElement => {
const { primaryColor } = useAppTheme();
const [activeTab, setActiveTab] = useState<'stats' | 'quota'>('stats');
const [stats, setStats] = useState<StorageStats | null>(null);
const [quota, setQuota] = useState<StorageQuota | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const loadData = async () => {
setLoading(true);
try {
const [statsRes, quotaRes] = await Promise.all([
fileAttachmentService.getStorageStats(),
fileAttachmentService.getQuota(),
]);
setStats(statsRes.data);
setQuota(quotaRes.data);
} catch (err) {
setError("Failed to load dashboard data");
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadData();
}, []);
if (loading) {
return (
<Layout currentPage="Storage Dashboard"
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
>
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="w-8 h-8 animate-spin" style={{ color: primaryColor }} />
</div>
</Layout>
);
}
if (error || !stats || !quota) {
return (
<Layout currentPage="Storage Dashboard"
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
>
<div className="max-w-md mx-auto mt-20 text-center">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<h2 className="text-lg font-bold">Error</h2>
<p className="text-sm text-[#9aa6b2] mt-1">{error}</p>
<PrimaryButton onClick={loadData} className="mt-4">Retry</PrimaryButton>
</div>
</Layout>
);
}
return (
<Layout
currentPage="Storage Dashboard"
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
pageHeader={{
title: "Storage Dashboard",
description: "Overview of storage consumption, file counts, and quota limits.",
}}
>
<div className="space-y-6">
{/* Tabs */}
<div className="flex border-b border-[rgba(0,0,0,0.08)]">
<button
onClick={() => setActiveTab('stats')}
className={cn(
"px-6 py-3 text-sm font-bold transition-all border-b-2",
activeTab === 'stats'
? "text-[#0e1b2a]"
: "border-transparent text-[#9aa6b2] hover:text-[#475569]"
)}
style={activeTab === 'stats' ? { borderBottomColor: primaryColor, color: primaryColor } : {}}
>
Usage Statistics
</button>
<button
onClick={() => setActiveTab('quota')}
className={cn(
"px-6 py-3 text-sm font-bold transition-all border-b-2",
activeTab === 'quota'
? "text-[#0e1b2a]"
: "border-transparent text-[#9aa6b2] hover:text-[#475569]"
)}
style={activeTab === 'quota' ? { borderBottomColor: primaryColor, color: primaryColor } : {}}
>
Quota Details
</button>
</div>
{activeTab === 'stats' && (
<div className="space-y-8 animate-in fade-in duration-300">
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg" style={{ backgroundColor: `${primaryColor}10` }}>
<HardDrive className="w-4 h-4" style={{ color: primaryColor }} />
</div>
<span className="text-xs font-bold text-[#9aa6b2] uppercase">Usage</span>
</div>
<p className="text-xl font-black text-[#0e1b2a]">{stats.quota.usage_percent}% <span className="text-[10px] text-[#9aa6b2] font-medium uppercase">capacity</span></p>
<div className="mt-2 w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full" style={{ width: `${stats.quota.usage_percent}%`, backgroundColor: primaryColor }} />
</div>
</div>
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-emerald-50 rounded-lg"><Files className="w-4 h-4 text-emerald-600" /></div>
<span className="text-xs font-bold text-[#9aa6b2] uppercase">Total Files</span>
</div>
<p className="text-xl font-black text-[#0e1b2a]">{stats.files.total}</p>
</div>
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-orange-50 rounded-lg"><ImageIcon className="w-4 h-4 text-orange-500" /></div>
<span className="text-xs font-bold text-[#9aa6b2] uppercase">Images</span>
</div>
<p className="text-xl font-black text-[#0e1b2a]">{stats.files.images}</p>
</div>
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-red-50 rounded-lg"><FileText className="w-4 h-4 text-red-500" /></div>
<span className="text-xs font-bold text-[#9aa6b2] uppercase">DOCs / PDFs</span>
</div>
<p className="text-xl font-black text-[#0e1b2a]">{stats.files.pdfs + stats.files.documents}</p>
</div>
</div>
{/* Tables Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Entity Table */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-2xl overflow-hidden shadow-sm">
<div className="px-5 py-4 border-b border-[rgba(0,0,0,0.08)] flex items-center gap-2">
<Building2 className="w-4 h-4" style={{ color: primaryColor }} />
<h3 className="text-sm font-bold text-[#0e1b2a]">By Entity Type</h3>
</div>
<table className="w-full text-left">
<thead>
<tr className="bg-gray-50/50 text-[10px] font-bold text-[#9aa6b2] uppercase">
<th className="px-5 py-3">Entity</th>
<th className="px-5 py-3">File Count</th>
<th className="px-5 py-3">Total Size</th>
</tr>
</thead>
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
{Object.entries(stats.by_entity).map(([name, data]) => (
<tr key={name} className="hover:bg-gray-50 transition-colors">
<td className="px-5 py-3 text-sm font-bold text-[#0e1b2a] capitalize">{name}</td>
<td className="px-5 py-3 text-sm text-[#475569]">{data.count}</td>
<td className="px-5 py-3 text-sm text-[#0e1b2a] font-medium">{formatBytes(data.size)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Module Table */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-2xl overflow-hidden shadow-sm">
<div className="px-5 py-4 border-b border-[rgba(0,0,0,0.08)] flex items-center gap-2">
<Package className="w-4 h-4 text-[#10b981]" />
<h3 className="text-sm font-bold text-[#0e1b2a]">By Source Module</h3>
</div>
<table className="w-full text-left">
<thead>
<tr className="bg-gray-50/50 text-[10px] font-bold text-[#9aa6b2] uppercase">
<th className="px-5 py-3">Module</th>
<th className="px-5 py-3">File Count</th>
<th className="px-5 py-3">Total Size</th>
</tr>
</thead>
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
{Object.entries(stats.by_module).map(([name, data]) => (
<tr key={name} className="hover:bg-gray-50 transition-colors">
<td className="px-5 py-3 text-sm font-bold text-[#0e1b2a] capitalize">{name}</td>
<td className="px-5 py-3 text-sm text-[#475569]">{data.count}</td>
<td className="px-5 py-3 text-sm text-[#0e1b2a] font-medium">{formatBytes(data.size)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{activeTab === 'quota' && (
<div className="space-y-6 animate-in slide-in-from-bottom-2 duration-300">
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-2xl overflow-hidden shadow-sm">
<div className="px-6 py-5 border-b border-[rgba(0,0,0,0.08)] flex items-center justify-between">
<div className="flex items-center gap-2">
<Database className="w-5 h-5" style={{ color: primaryColor }} />
<h3 className="text-base font-black text-[#0e1b2a]">Quota Profile</h3>
</div>
<PrimaryButton
onClick={() => setIsEditModalOpen(true)}
size="default"
className="px-4 py-2.5 text-sm"
>
<Pencil className="w-3.5 h-3.5" />
Edit Quota
</PrimaryButton>
</div>
<div className="p-0">
<table className="w-full">
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
{[
{ label: "Max Total Storage", value: quota.max_storage_formatted || formatBytes(quota.max_storage_bytes), icon: HardDrive },
{ label: "Max Per-File Size", value: quota.max_file_size_formatted || formatBytes(quota.max_file_size_bytes), icon: FileText },
{ label: "Currently Used", value: quota.used_storage_formatted || formatBytes(quota.used_storage_bytes), icon: Save },
{ label: "File Count", value: `${quota.file_count} items`, icon: Files },
{ label: "Last Updated", value: new Date(quota.updated_at).toLocaleString(), icon: CheckCircle2 },
].map((row) => (
<tr key={row.label}>
<td className="px-6 py-4 flex items-center gap-3 w-1/3">
<row.icon className="w-4 h-4 text-[#9aa6b2]" />
<span className="text-sm font-medium text-[#475569]">{row.label}</span>
</td>
<td className="px-6 py-4 text-sm font-bold text-[#0e1b2a]">{row.value}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="p-6 border rounded-2xl flex items-start gap-4" style={{ backgroundColor: `${primaryColor}05`, borderColor: `${primaryColor}10` }}>
<ShieldCheck className="w-6 h-6 shrink-0" style={{ color: primaryColor }} />
<div>
<h4 className="text-sm font-bold text-[#0e1b2a]">System Security Policy</h4>
<p className="text-sm text-[#475569] mt-1 leading-relaxed">
The following extensions are strictly blocked to prevent malicious execution:
</p>
<div className="flex flex-wrap gap-1.5 mt-3">
{quota.blocked_extensions?.map(ext => (
<span key={ext} className="px-2 py-0.5 bg-red-100/50 text-red-700 text-[10px] font-black rounded border border-red-200 uppercase">
{ext}
</span>
))}
</div>
</div>
</div>
</div>
)}
</div>
{isEditModalOpen && (
<QuotaEditModal
isOpen={isEditModalOpen}
onClose={() => setIsEditModalOpen(false)}
quota={quota}
onUpdated={loadData}
/>
)}
</Layout>
);
};
export default StorageDashboard;

View File

@ -11,6 +11,7 @@ import { workflowService } from "@/services/workflow-service";
import { moduleService } from "@/services/module-service";
import type { WorkflowTask, WorkflowTaskCounts } from "@/types/workflow";
import { cn } from "@/lib/utils";
import { useAppTheme } from "@/hooks/useAppTheme";
import { Inbox, Clock, Calendar, CheckCircle2, RotateCcw } from "lucide-react";
const formatDate = (value?: string | null): string => {
@ -24,9 +25,12 @@ const formatDate = (value?: string | null): string => {
});
};
const StatCard = ({ icon: Icon, label, value, color }: { icon: any, label: string, value: number, color: string }) => (
const StatCard = ({ icon: Icon, label, value, color, style }: { icon: any, label: string, value: number, color?: string, style?: React.CSSProperties }) => (
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl p-4 flex items-center gap-4 shadow-sm">
<div className={cn("w-12 h-12 rounded-lg flex items-center justify-center", color)}>
<div
className={cn("w-12 h-12 rounded-lg flex items-center justify-center shrink-0", color)}
style={style}
>
<Icon className="w-6 h-6 text-white" />
</div>
<div>
@ -37,6 +41,7 @@ const StatCard = ({ icon: Icon, label, value, color }: { icon: any, label: strin
);
const Tasks = (): ReactElement => {
const { primaryColor } = useAppTheme();
const navigate = useNavigate();
const [tasks, setTasks] = useState<WorkflowTask[]>([]);
const [counts, setCounts] = useState<WorkflowTaskCounts | null>(null);
@ -124,7 +129,10 @@ const Tasks = (): ReactElement => {
label: "Step",
render: (task) => (
<div className="flex items-center gap-2">
<span className="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-[11px] font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">
<span
className="inline-flex items-center rounded-md px-2 py-1 text-[11px] font-medium"
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
>
{task.step.name}
</span>
</div>
@ -176,7 +184,8 @@ const Tasks = (): ReactElement => {
navigate(`/tenant/documents/${task.entity.id}`);
}
}}
className="text-[#084cc8] hover:text-[#063ba1] font-bold text-sm"
className="font-bold text-sm transition-colors hover:opacity-80"
style={{ color: primaryColor }}
>
View
</button>
@ -201,7 +210,7 @@ const Tasks = (): ReactElement => {
icon={Inbox}
label="Pending Tasks"
value={counts?.pending || 0}
color="bg-blue-600"
style={{ backgroundColor: primaryColor }}
/>
<StatCard
icon={Clock}

View File

@ -19,6 +19,7 @@ import { userService } from "@/services/user-service";
import type { User } from "@/types/user";
import { showToast } from "@/utils/toast";
import { usePermissions } from "@/hooks/usePermissions";
import { useAppTheme } from "@/hooks/useAppTheme";
// Helper function to get user initials
const getUserInitials = (firstName: string, lastName: string): string => {
@ -56,6 +57,7 @@ const getStatusVariant = (
};
const Users = (): ReactElement => {
const { primaryColor } = useAppTheme();
const { canCreate, canUpdate
// , canDelete
} = usePermissions();
@ -268,7 +270,8 @@ const Users = (): ReactElement => {
user.role_module_combinations.map((combo, idx) => (
<span
key={`${combo.role_id}-${combo.module_id || 'global'}-${idx}`}
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-[#112868]/10 text-[#112868]"
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium"
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
title={combo.module_name ? `Role for ${combo.module_name}` : "Global Role"}
>
{combo.role_name} {combo.module_name && `(${combo.module_name})`}
@ -278,7 +281,8 @@ const Users = (): ReactElement => {
user.roles.map((role) => (
<span
key={role.id}
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-[#112868]/10 text-[#112868]"
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium"
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
>
{role.name}
</span>

View File

@ -19,6 +19,7 @@ import type { DocumentDetail, DocumentVersion } from "@/types/document";
import type { WorkflowInstance } from "@/types/workflow";
import { cn } from "@/lib/utils";
import { showToast } from "@/utils/toast";
import { useAppTheme } from "@/hooks/useAppTheme";
import { Paperclip, Plus, User } from "lucide-react";
const formatDateTime = (value?: string | null): string => {
@ -52,6 +53,7 @@ const ACTION_LABELS: Record<DocumentAction, string> = {
};
const ViewDocument = (): ReactElement => {
const { primaryColor } = useAppTheme();
const navigate = useNavigate();
const { id } = useParams();
const [document, setDocument] = useState<DocumentDetail | null>(null);
@ -504,7 +506,8 @@ const ViewDocument = (): ReactElement => {
</button>
<button
type="button"
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-[#084cc8] text-white text-xs font-medium hover:bg-[#063a99] transition-colors"
className="inline-flex items-center gap-2 h-9 px-4 rounded-md text-white text-xs font-medium transition-colors hover:opacity-90"
style={{ backgroundColor: primaryColor }}
onClick={() => void openActionModal("submit")}
>
{ACTION_LABELS["submit"]}
@ -515,7 +518,8 @@ const ViewDocument = (): ReactElement => {
<>
<button
type="button"
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-[#112868] text-white text-xs font-medium hover:bg-[#0c1d4a]"
className="inline-flex items-center gap-2 h-9 px-4 rounded-md text-white text-xs font-medium opacity-90 hover:opacity-100"
style={{ backgroundColor: primaryColor }}
onClick={() => void openWorkflowTracker()}
>
Workflow Status
@ -540,7 +544,8 @@ const ViewDocument = (): ReactElement => {
<div className="flex items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-[#084cc8] text-white text-xs font-medium hover:bg-[#063a99]"
className="inline-flex items-center gap-2 h-9 px-4 rounded-md text-white text-xs font-medium hover:opacity-90"
style={{ backgroundColor: primaryColor }}
onClick={() => void openActionModal("effective")}
>
{ACTION_LABELS["effective"]}
@ -587,7 +592,10 @@ const ViewDocument = (): ReactElement => {
<span className="px-2 py-1 rounded-full bg-amber-100 text-amber-700 text-[11px] font-medium">
{document?.document_type || "-"}
</span>
<span className="px-2 py-1 rounded-full bg-blue-100 text-blue-700 text-[11px] font-medium">
<span
className="px-2 py-1 rounded-full text-[11px] font-medium"
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
>
v{document?.current_version || "-"}
</span>
{document?.module_name && (
@ -602,21 +610,33 @@ const ViewDocument = (): ReactElement => {
<div className="flex items-center gap-5 border-b border-[rgba(0,0,0,0.08)] mb-4">
<button
type="button"
className={`text-sm pb-2 ${activeTab === "overview" ? "text-[#084cc8] border-b-2 border-[#084cc8]" : "text-[#6b7280]"}`}
className={`text-sm pb-2 transition-colors`}
style={{
color: activeTab === "overview" ? primaryColor : "#6b7280",
borderBottom: activeTab === "overview" ? `2px solid ${primaryColor}` : "none"
}}
onClick={() => setActiveTab("overview")}
>
Overview
</button>
<button
type="button"
className={`text-sm pb-2 ${activeTab === "version-history" ? "text-[#084cc8] border-b-2 border-[#084cc8]" : "text-[#6b7280]"}`}
className={`text-sm pb-2 transition-colors`}
style={{
color: activeTab === "version-history" ? primaryColor : "#6b7280",
borderBottom: activeTab === "version-history" ? `2px solid ${primaryColor}` : "none"
}}
onClick={() => setActiveTab("version-history")}
>
Version History
</button>
<button
type="button"
className={`text-sm pb-2 ${activeTab === "workflow-history" ? "text-[#084cc8] border-b-2 border-[#084cc8]" : "text-[#6b7280]"}`}
className={`text-sm pb-2 transition-colors`}
style={{
color: activeTab === "workflow-history" ? primaryColor : "#6b7280",
borderBottom: activeTab === "workflow-history" ? `2px solid ${primaryColor}` : "none"
}}
onClick={() => setActiveTab("workflow-history")}
>
Workflow History
@ -701,7 +721,8 @@ const ViewDocument = (): ReactElement => {
<div className="flex justify-end">
<button
type="button"
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md bg-[#112868] text-white text-xs"
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-white text-xs hover:opacity-90 transition-colors"
style={{ backgroundColor: primaryColor }}
onClick={() => {
setShowNewVersionForm((prev) => !prev);
setNewVersionContent(document.content || "");

View File

@ -6,10 +6,10 @@ const WorkflowDefinationPage = (): ReactElement => {
return (
<Layout
currentPage="Workflow Definitions"
breadcrumbs={[
{ label: "Platform", path: "/tenant" },
{ label: "Workflow Definitions" },
]}
// breadcrumbs={[
// // { label: "Platform", path: "/tenant" },
// { label: "Workflow Definitions" },
// ]}
>
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">

View File

@ -19,13 +19,20 @@ const Documents = lazy(() => import("@/pages/tenant/Documents"));
const CreateDocument = lazy(() => import("@/pages/tenant/CreateDocument"));
const EditDocument = lazy(() => import("@/pages/tenant/EditDocument"));
const ViewDocument = lazy(() => import("@/pages/tenant/ViewDocument"));
const DocumentCategories = lazy(() => import("@/pages/tenant/DocumentCategories"));
const DocumentsDueForReview = lazy(() => import("@/pages/tenant/DocumentsDueForReview"));
const DocumentCategories = lazy(
() => import("@/pages/tenant/DocumentCategories"),
);
const DocumentsDueForReview = lazy(
() => import("@/pages/tenant/DocumentsDueForReview"),
);
const Tasks = lazy(() => import("@/pages/tenant/Tasks"));
const NotificationSettings = lazy(() => import("@/pages/tenant/NotificationSettings"));
const NotificationSettings = lazy(
() => import("@/pages/tenant/NotificationSettings"),
);
const Notifications = lazy(() => import("@/pages/tenant/Notifications"));
const FilesList = lazy(() => import("@/pages/tenant/FilesList"));
const FileView = lazy(() => import("@/pages/tenant/FileView"));
const StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard"));
// Loading fallback component
const RouteLoader = (): ReactElement => (
@ -140,4 +147,8 @@ export const tenantAdminRoutes: RouteConfig[] = [
path: "/tenant/files/:id",
element: <LazyRoute component={FileView} />,
},
{
path: "/tenant/files/storage-dashboard",
element: <LazyRoute component={StorageDashboard} />,
},
];

View File

@ -1,5 +1,5 @@
export interface StatCardData {
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
icon: React.ComponentType<{ className?: string; strokeWidth?: number; color?: string; style?: React.CSSProperties }>;
value: string | number;
label: string;
badge?: {
@ -17,7 +17,7 @@ export interface ActivityLog {
}
export interface QuickAction {
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
icon: React.ComponentType<{ className?: string; strokeWidth?: number; color?: string; style?: React.CSSProperties }>;
label: string;
onClick: () => void;
}