Compare commits

...

2 Commits

31 changed files with 1583 additions and 638 deletions

View File

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

View File

@ -1,8 +1,9 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { ReactElement } from 'react'; 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 { cn } from '@/lib/utils';
import { useAppTheme } from '@/hooks/useAppTheme';
export interface ActionItem { export interface ActionItem {
label: string; label: string;
@ -17,6 +18,7 @@ interface ActionDropdownProps {
onDelete?: () => void; onDelete?: () => void;
onContacts?: () => void; onContacts?: () => void;
onScorecards?: () => void; onScorecards?: () => void;
onDownload?: () => void;
actions?: ActionItem[]; actions?: ActionItem[];
trigger?: React.ReactNode; trigger?: React.ReactNode;
className?: string; className?: string;
@ -28,10 +30,12 @@ export const ActionDropdown = ({
onDelete, onDelete,
onContacts, onContacts,
onScorecards, onScorecards,
onDownload,
actions, actions,
trigger, trigger,
className, className,
}: ActionDropdownProps): ReactElement => { }: ActionDropdownProps): ReactElement => {
const { primaryColor } = useAppTheme();
const [isOpen, setIsOpen] = useState<boolean>(false); const [isOpen, setIsOpen] = useState<boolean>(false);
const [dropdownStyle, setDropdownStyle] = useState<{ top?: string; bottom?: string; right: string; width: string }>({ right: '0', width: '0' }); const [dropdownStyle, setDropdownStyle] = useState<{ top?: string; bottom?: string; right: string; width: string }>({ right: '0', width: '0' });
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@ -67,14 +71,22 @@ export const ActionDropdown = ({
const rect = buttonRef.current.getBoundingClientRect(); const rect = buttonRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom; const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top; 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 // Determine if should open upward or downward
const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow; const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
// Calculate dropdown position // Calculate dropdown position
const right = window.innerWidth - rect.right; 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) { if (shouldOpenUp) {
// Position above the button // Position above the button
@ -125,12 +137,25 @@ export const ActionDropdown = ({
ref={buttonRef} ref={buttonRef}
type="button" type="button"
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className={cn( className="flex items-center justify-center w-7 h-7 rounded-full border border-[rgba(0,0,0,0.08)] transition-colors cursor-pointer"
'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
isOpen ? { backgroundColor: primaryColor, color: 'white', borderColor: primaryColor }
? 'bg-[#084cc8] text-white' : { backgroundColor: 'white', color: '#0f1724' }
: 'bg-white text-[#0f1724] hover:bg-[#084cc8] hover:text-white' }
)} 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-label="Actions"
aria-expanded={isOpen} aria-expanded={isOpen}
> >
@ -169,9 +194,9 @@ export const ActionDropdown = ({
<button <button
type="button" type="button"
onClick={() => handleAction(onView)} 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> <span>View</span>
</button> </button>
)} )}
@ -179,9 +204,9 @@ export const ActionDropdown = ({
<button <button
type="button" type="button"
onClick={() => handleAction(onEdit)} 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> <span>Edit</span>
</button> </button>
)} )}
@ -189,9 +214,9 @@ export const ActionDropdown = ({
<button <button
type="button" type="button"
onClick={() => handleAction(onDelete)} 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> <span>Delete</span>
</button> </button>
)} )}
@ -199,9 +224,9 @@ export const ActionDropdown = ({
<button <button
type="button" type="button"
onClick={() => handleAction(onContacts)} 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> <span>Contacts</span>
</button> </button>
)} )}
@ -209,12 +234,22 @@ export const ActionDropdown = ({
<button <button
type="button" type="button"
onClick={() => handleAction(onScorecards)} 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> <span>Scorecards</span>
</button> </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> </div>

View File

@ -12,6 +12,7 @@ interface DeleteConfirmationModalProps {
message: string; message: string;
itemName?: string; itemName?: string;
isLoading?: boolean; isLoading?: boolean;
children?: React.ReactNode;
} }
export const DeleteConfirmationModal = ({ export const DeleteConfirmationModal = ({
@ -22,6 +23,7 @@ export const DeleteConfirmationModal = ({
message, message,
itemName, itemName,
isLoading = false, isLoading = false,
children,
}: DeleteConfirmationModalProps): ReactElement | null => { }: DeleteConfirmationModalProps): ReactElement | null => {
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
@ -65,10 +67,10 @@ export const DeleteConfirmationModal = ({
if (!isOpen) return null; if (!isOpen) return null;
const modalContent = ( 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 <div
ref={modalRef} 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 */} {/* Modal Header */}
<div className="flex items-start justify-between pb-4 pt-5 px-5 border-b border-[rgba(0,0,0,0.08)]"> <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. ? This action cannot be undone.
</p> </p>
{children && <div className="mt-4">{children}</div>}
</div> </div>
{/* Modal Footer */} {/* Modal Footer */}

View File

@ -13,6 +13,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import fileAttachmentService, { type FileAttachment } from "@/services/file-attachment-service"; import fileAttachmentService, { type FileAttachment } from "@/services/file-attachment-service";
import { DeleteConfirmationModal } from "./DeleteConfirmationModal";
interface FileShareModalProps { interface FileShareModalProps {
isOpen: boolean; isOpen: boolean;
@ -30,7 +31,9 @@ export const FileShareModal: React.FC<FileShareModalProps> = ({
const [permissions, setPermissions] = useState<"view" | "download">("download"); const [permissions, setPermissions] = useState<"view" | "download">("download");
const [isSharing, setIsSharing] = useState(false); 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 [copied, setCopied] = useState(false);
const handleCreateShare = async () => { const handleCreateShare = async () => {
@ -45,7 +48,7 @@ export const FileShareModal: React.FC<FileShareModalProps> = ({
}); });
const fullUrl = `${baseUrl}/files/shared/${res.data.share_token}`; 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) { } catch (error) {
console.error("Failed to share:", error); console.error("Failed to share:", error);
} finally { } 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 () => { const copyToClipboard = async () => {
if (!shareData) return; if (!shareData) return;
try { try {
@ -90,6 +109,7 @@ export const FileShareModal: React.FC<FileShareModalProps> = ({
title="Share File" title="Share File"
description={file.original_name} description={file.original_name}
maxWidth="md" maxWidth="md"
preventCloseOnClickOutside={showRevokeConfirm}
> >
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{!shareData ? ( {!shareData ? (
@ -204,19 +224,33 @@ export const FileShareModal: React.FC<FileShareModalProps> = ({
</div> </div>
</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 <button
onClick={() => setShareData(null)} onClick={() => setShowRevokeConfirm(true)}
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" 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 {isRevoking ? (
</button> <Loader2 className="w-3.5 h-3.5 animate-spin" />
<button ) : (
onClick={() => window.open(shareData.url, '_blank')} "Revoke Link (Stop Sharing)"
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> </button>
</div> </div>
</div> </div>
@ -229,6 +263,15 @@ export const FileShareModal: React.FC<FileShareModalProps> = ({
</p> </p>
</div> </div>
</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> </Modal>
); );
}; };

View File

@ -14,7 +14,6 @@ import {
type ReactElement, type ReactElement,
useEffect, useEffect,
} from "react"; } from "react";
import { createPortal } from "react-dom";
import { import {
X, X,
Upload, Upload,
@ -29,6 +28,14 @@ import {
ChevronDown as ChevronDownIcon, ChevronDown as ChevronDownIcon,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
Modal,
PrimaryButton,
SecondaryButton,
FormField,
FormSelect,
FormTextArea,
} from "@/components/shared";
import fileAttachmentService, { import fileAttachmentService, {
type CategoriesFilterOptions, type CategoriesFilterOptions,
} from "@/services/file-attachment-service"; } from "@/services/file-attachment-service";
@ -328,348 +335,324 @@ export const FileUploadModal = ({
const validCount = files.filter((f) => f.status !== "blocked").length; const validCount = files.filter((f) => f.status !== "blocked").length;
const doneCount = files.filter((f) => f.status === "done").length; const doneCount = files.filter((f) => f.status === "done").length;
const content = ( const footer = (
<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"> <SecondaryButton
{/* Header */} onClick={handleClose}
<div className="flex items-start justify-between px-6 pt-6 pb-4 border-b border-[rgba(0,0,0,0.08)] shrink-0"> disabled={isUploading}
<div> >
<h2 className="text-[17px] font-semibold text-[#0e1b2a]">Upload New File</h2> Cancel
<p className="text-sm text-[#9aa6b2] mt-0.5">Attach files via File Attachment Service</p> </SecondaryButton>
</div> <PrimaryButton
<button onClick={handleUpload}
onClick={handleClose} disabled={isUploading || files.length === 0 || validCount === 0}
disabled={isUploading} className="flex items-center gap-2"
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors text-[#6b7280] disabled:opacity-40" >
> {isUploading ? (
<X className="w-4 h-4" /> <>
</button> <Loader2 className="w-4 h-4 animate-spin" />
</div> Uploading ({doneCount}/{validCount})
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload {validCount > 0 ? `(${validCount})` : ""}
</>
)}
</PrimaryButton>
</>
);
{/* Scrollable body */} return (
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 space-y-5"> <Modal
isOpen={isOpen}
{/* Drop Zone */} onClose={handleClose}
<div> title="Upload New File"
<p className="text-xs font-semibold text-[#0e1b2a] mb-2 uppercase tracking-wide">Attach Files</p> description="Attach files via File Attachment Service"
{files.length === 0 ? ( maxWidth="md"
<div footer={footer}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }} >
onDragLeave={() => setIsDragging(false)} <div className="px-6 py-5 space-y-5">
onDrop={onDrop} {/* Drop Zone */}
onClick={() => inputRef.current?.click()} <div>
className={cn( <p className="text-xs font-semibold text-[#0e1b2a] mb-2 uppercase tracking-wide">Attach Files</p>
"border-2 border-dashed rounded-xl flex flex-col items-center justify-center gap-3 py-8 cursor-pointer transition-all", {files.length === 0 ? (
isDragging <div
? "border-[#084cc8] bg-[#084cc8]/5" onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
: "border-[rgba(0,0,0,0.12)] hover:border-[#084cc8]/50 hover:bg-gray-50" onDragLeave={() => setIsDragging(false)}
)} onDrop={onDrop}
> onClick={() => inputRef.current?.click()}
<div className="w-10 h-10 rounded-full bg-[#084cc8]/10 flex items-center justify-center"> className={cn(
<Upload className="w-5 h-5 text-[#084cc8]" /> "border-2 border-dashed rounded-xl flex flex-col items-center justify-center gap-3 py-8 cursor-pointer transition-all",
</div> isDragging
<div className="text-center"> ? "border-[#084cc8] bg-[#084cc8]/5"
<p className="text-sm font-medium text-[#0e1b2a]">Click to upload or drag and drop</p> : "border-[rgba(0,0,0,0.12)] hover:border-[#084cc8]/50 hover:bg-gray-50"
<p className="text-xs text-[#9aa6b2] mt-0.5"> )}
Attach supporting source files via File Attachment Service >
</p> <div className="w-10 h-10 rounded-full bg-[#084cc8]/10 flex items-center justify-center">
<p className="text-xs text-[#9aa6b2]">PDF, DOCX, XLSX up to {MAX_SIZE_MB}MB</p> <Upload className="w-5 h-5 text-[#084cc8]" />
</div>
</div> </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 <div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }} onClick={() => inputRef.current?.click()}
onDragLeave={() => setIsDragging(false)} 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"
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 className="w-7 h-7 rounded-full bg-[#084cc8] flex items-center justify-center text-white">
<div <Upload className="w-3.5 h-3.5" />
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> </div>
<span className="text-sm font-semibold text-[#084cc8]">Add Files</span>
</div>
{/* File list */} {/* File list */}
<div className="divide-y divide-[rgba(0,0,0,0.06)]"> <div className="divide-y divide-[rgba(0,0,0,0.06)]">
{files.map((entry) => ( {files.map((entry) => (
<div key={entry.id} className="flex items-center gap-3 px-4 py-2.5"> <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="shrink-0">{getFileIcon(entry.file.type, entry.file.name)}</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-[#0e1b2a] truncate max-w-[180px]"> <span className="text-sm font-medium text-[#0e1b2a] truncate max-w-[180px]">
{entry.file.name} {entry.file.name}
</span> </span>
<span className="text-xs text-[#9aa6b2] shrink-0"> <span className="text-xs text-[#9aa6b2] shrink-0">
{formatBytes(entry.file.size)} {formatBytes(entry.file.size)}
</span> </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>
{entry.status === "uploading" && ( {entry.status === "uploading" && (
<div className="mt-1.5 h-1 bg-gray-100 rounded-full overflow-hidden"> <span className="text-xs font-semibold text-[#084cc8] shrink-0">
<div {entry.progress}%
className="h-full bg-[#084cc8] rounded-full transition-all duration-300" </span>
style={{ width: `${entry.progress}%` }}
/>
</div>
)} )}
{entry.status === "done" && ( {entry.status === "done" && (
<div className="mt-1.5 h-1 bg-emerald-100 rounded-full overflow-hidden"> <span className="text-xs font-semibold text-emerald-600 shrink-0 flex items-center gap-1">
<div className="h-full bg-emerald-500 rounded-full w-full" /> <CheckCircle2 className="w-3 h-3" /> Complete
</div> </span>
)} )}
{(entry.status === "blocked" || entry.status === "error") && ( {(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> </div>
{entry.status !== "uploading" && entry.status !== "done" && ( {entry.status === "uploading" && (
<button <div className="mt-1.5 h-1 bg-gray-100 rounded-full overflow-hidden">
onClick={() => removeFile(entry.id)} <div
className="shrink-0 p-1 rounded hover:bg-gray-100 text-[#9aa6b2]" className="h-full bg-[#084cc8] rounded-full transition-all duration-300"
> style={{ width: `${entry.progress}%` }}
<X className="w-3.5 h-3.5" /> />
</button> </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>
))} {entry.status !== "uploading" && entry.status !== "done" && (
</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) => (
<button <button
key={cat.category_id ?? cat.category} onClick={() => removeFile(entry.id)}
onClick={() => { className="shrink-0 p-1 rounded hover:bg-gray-100 text-[#9aa6b2]"
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} <X className="w-3.5 h-3.5" />
{categoryInput === cat.category && <CheckCircle2 className="w-3.5 h-3.5" />}
</button> </button>
)) )}
) : (
<div className="px-4 py-2 text-xs text-[#9aa6b2] italic">
{categorySearch ? `Press enter to use "${categorySearch}"` : "No categories found"}
</div> </div>
)} ))}
</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>
</div> )}
<p className="text-[11px] text-[#9aa6b2] mt-2">Up to {MAX_FILES} files allowed</p>
{/* Description */} {uploadSuccess && (
<div> <div className="flex items-center gap-1.5 mt-2 text-emerald-600">
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">Description</label> <CheckCircle2 className="w-3.5 h-3.5" />
<textarea <span className="text-xs font-semibold">Files upload successfully.</span>
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>
</div> </div>
)} )}
</div> </div>
{/* Footer */} <input
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[rgba(0,0,0,0.08)] shrink-0"> ref={inputRef}
<button type="file"
onClick={handleClose} multiple
disabled={isUploading} className="hidden"
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" onChange={onInputChange}
> />
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>
);
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; 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 type { ReactElement } from 'react';
import { ChevronDown } from 'lucide-react'; import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAppTheme } from '@/hooks/useAppTheme';
interface FilterOption { interface FilterOption {
value: string | string[]; value: string | string[];
@ -30,6 +31,7 @@ export const FilterDropdown = ({
icon, icon,
isSearchable = false, isSearchable = false,
}: FilterDropdownProps): ReactElement => { }: FilterDropdownProps): ReactElement => {
const { primaryColor } = useAppTheme();
const [isOpen, setIsOpen] = useState<boolean>(false); const [isOpen, setIsOpen] = useState<boolean>(false);
const [localSearch, setLocalSearch] = useState<string>(''); const [localSearch, setLocalSearch] = useState<string>('');
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@ -133,13 +135,17 @@ export const FilterDropdown = ({
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className={cn( 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", "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>} {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 && ( {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} {displayText}
</span> </span>
)} )}
@ -164,7 +170,19 @@ export const FilterDropdown = ({
placeholder="Search..." placeholder="Search..."
value={localSearch} value={localSearch}
onChange={(e) => setLocalSearch(e.target.value)} 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 autoFocus
/> />
</div> </div>

View File

@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react'; import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAppTheme } from '@/hooks/useAppTheme';
interface LimitOption { interface LimitOption {
value: string; value: string;
@ -36,6 +37,7 @@ export const Pagination = ({
onLimitChange, onLimitChange,
limitOptions = defaultLimitOptions, limitOptions = defaultLimitOptions,
}: PaginationProps): ReactElement => { }: PaginationProps): ReactElement => {
const { primaryColor } = useAppTheme();
const [isLimitOpen, setIsLimitOpen] = useState<boolean>(false); const [isLimitOpen, setIsLimitOpen] = useState<boolean>(false);
const limitDropdownRef = useRef<HTMLDivElement>(null); const limitDropdownRef = useRef<HTMLDivElement>(null);
const limitButtonRef = useRef<HTMLButtonElement>(null); const limitButtonRef = useRef<HTMLButtonElement>(null);
@ -189,7 +191,18 @@ export const Pagination = ({
type="button" type="button"
onClick={handleNext} onClick={handleNext}
disabled={currentPage >= totalPages} 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> <span className="hidden sm:inline">Next</span>
<ChevronRight className="w-3.5 h-3.5" /> <ChevronRight className="w-3.5 h-3.5" />

View File

@ -1,7 +1,7 @@
import type { ReactElement, ButtonHTMLAttributes } from 'react'; import type { ReactElement, ButtonHTMLAttributes } from 'react';
import { cva, type VariantProps } from 'class-variance-authority'; import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAppSelector } from '@/hooks/redux-hooks'; import { useAppTheme } from '@/hooks/useAppTheme';
const primaryButtonVariants = cva( 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', '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 ...props
}: PrimaryButtonProps): ReactElement => { }: PrimaryButtonProps): ReactElement => {
const buttonVariant = disabled ? 'disabled' : variant || 'default'; const buttonVariant = disabled ? 'disabled' : variant || 'default';
const { theme } = useAppSelector((state) => state.theme); const { primaryColor, secondaryColor } = useAppTheme();
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';
return ( return (
<button <button

View File

@ -1,7 +1,7 @@
import type { ReactElement, ButtonHTMLAttributes } from 'react'; import type { ReactElement, ButtonHTMLAttributes } from 'react';
import { cva, type VariantProps } from 'class-variance-authority'; import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAppSelector } from '@/hooks/redux-hooks'; import { useAppTheme } from '@/hooks/useAppTheme';
const secondaryButtonVariants = cva( 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', '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 ...props
}: SecondaryButtonProps): ReactElement => { }: SecondaryButtonProps): ReactElement => {
const buttonVariant = disabled ? 'disabled' : variant || 'default'; const buttonVariant = disabled ? 'disabled' : variant || 'default';
const { theme } = useAppSelector((state) => state.theme); const { primaryColor, secondaryColor } = useAppTheme();
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';
return ( return (
<button <button

View File

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

View File

@ -16,6 +16,7 @@ import { Plus, Building2 } from "lucide-react";
import { supplierService } from "@/services/supplier-service"; import { supplierService } from "@/services/supplier-service";
import type { Supplier } from "@/types/supplier"; import type { Supplier } from "@/types/supplier";
import { formatDate } from "@/utils/format-date"; import { formatDate } from "@/utils/format-date";
import { useAppTheme } from "@/hooks/useAppTheme";
interface SuppliersTableProps { interface SuppliersTableProps {
tenantId?: string | null; tenantId?: string | null;
@ -59,6 +60,7 @@ export const SuppliersTable = ({
showHeader = true, showHeader = true,
compact = false, compact = false,
}: SuppliersTableProps): ReactElement => { }: SuppliersTableProps): ReactElement => {
const { primaryColor } = useAppTheme();
const [suppliers, setSuppliers] = useState<Supplier[]>([]); const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -288,7 +290,20 @@ export const SuppliersTable = ({
<input <input
type="text" type="text"
placeholder="Search suppliers..." 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} value={searchQuery}
onChange={(e) => { onChange={(e) => {
setSearchQuery(e.target.value); setSearchQuery(e.target.value);

View File

@ -24,6 +24,7 @@ import type {
} from "@/types/department"; } from "@/types/department";
import { showToast } from "@/utils/toast"; import { showToast } from "@/utils/toast";
import type { RootState } from "@/store/store"; import type { RootState } from "@/store/store";
import { useAppTheme } from "@/hooks/useAppTheme";
interface DepartmentsTableProps { interface DepartmentsTableProps {
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode) tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
@ -36,6 +37,7 @@ const DepartmentsTable = ({
compact = false, compact = false,
showHeader = true, showHeader = true,
}: DepartmentsTableProps): ReactElement => { }: DepartmentsTableProps): ReactElement => {
const { primaryColor } = useAppTheme();
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId); const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
const effectiveTenantId = propsTenantId || reduxTenantId; const effectiveTenantId = propsTenantId || reduxTenantId;
@ -257,7 +259,20 @@ const DepartmentsTable = ({
<input <input
type="text" type="text"
placeholder="Search departments..." 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} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />

View File

@ -24,6 +24,7 @@ import type {
} from "@/types/designation"; } from "@/types/designation";
import { showToast } from "@/utils/toast"; import { showToast } from "@/utils/toast";
import type { RootState } from "@/store/store"; import type { RootState } from "@/store/store";
import { useAppTheme } from "@/hooks/useAppTheme";
interface DesignationsTableProps { interface DesignationsTableProps {
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode) tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
@ -36,6 +37,7 @@ const DesignationsTable = ({
compact = false, compact = false,
showHeader = true, showHeader = true,
}: DesignationsTableProps): ReactElement => { }: DesignationsTableProps): ReactElement => {
const { primaryColor } = useAppTheme();
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId); const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
const effectiveTenantId = propsTenantId || reduxTenantId; const effectiveTenantId = propsTenantId || reduxTenantId;
@ -248,7 +250,20 @@ const DesignationsTable = ({
<input <input
type="text" type="text"
placeholder="Search designations..." 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} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} 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 { Plus, UserPlus, Shield, Settings, Building2, BadgeCheck } from 'lucide-react';
import { useAppSelector } from '@/hooks/redux-hooks'; import { useAppSelector } from '@/hooks/redux-hooks';
import type { QuickAction } from '@/types/dashboard'; import type { QuickAction } from '@/types/dashboard';
import { useAppTheme } from '@/hooks/useAppTheme';
export const QuickActions = () => { export const QuickActions = () => {
const { primaryColor } = useAppTheme();
const navigate = useNavigate(); const navigate = useNavigate();
const { roles, permissions } = useAppSelector((state) => state.auth); 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" 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"> <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> </div>
<span className="text-[11px] font-bold text-[#111827] text-center leading-none"> <span className="text-[11px] font-bold text-[#111827] text-center leading-none">
{action.label} {action.label}

View File

@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
import { auditLogService } from '@/services/audit-log-service'; import { auditLogService } from '@/services/audit-log-service';
import type { AuditLog } from '@/types/audit-log'; import type { AuditLog } from '@/types/audit-log';
import { useAppSelector } from '@/hooks/redux-hooks'; import { useAppSelector } from '@/hooks/redux-hooks';
import { useAppTheme } from '@/hooks/useAppTheme';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Card, CardHeader, CardContent } from '@/components/ui/card'; import { Card, CardHeader, CardContent } from '@/components/ui/card';
import { StatusBadge } from '@/components/shared'; import { StatusBadge } from '@/components/shared';
@ -46,6 +47,7 @@ export interface RecentActivityProps {
} }
export const RecentActivity = ({ variant }: RecentActivityProps) => { export const RecentActivity = ({ variant }: RecentActivityProps) => {
const { primaryColor } = useAppTheme();
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]); const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const { tenantId } = useAppSelector((state) => state.auth); const { tenantId } = useAppSelector((state) => state.auth);
@ -80,7 +82,8 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
<Button <Button
variant="ghost" variant="ghost"
size="sm" 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')} onClick={() => navigate('/tenant/audit-logs')}
> >
View All <ArrowRight className="w-3 h-3" /> View All <ArrowRight className="w-3 h-3" />
@ -90,7 +93,7 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
<div className="flex-1"> <div className="flex-1">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-20"> <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> </div>
) : auditLogs.length === 0 ? ( ) : auditLogs.length === 0 ? (
<div className="p-12 text-center text-[12px] text-gray-400 font-medium">No recent activity</div> <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>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap"> <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">{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 hover:underline cursor-pointer truncate"
style={{ color: primaryColor }}
>
{log.resource_type}
</span>
</div> </div>
</div> </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> <span className="text-[12px] font-medium text-gray-500">{formatRelativeTime(log.created_at)}</span>
</td> </td>
<td className="px-5 py-4 whitespace-nowrap"> <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>
<td className="px-5 py-4 whitespace-nowrap"> <td className="px-5 py-4 whitespace-nowrap">
<StatusBadge variant={getMethodVariant(log.request_method)}> <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 { auditLogService } from '@/services/audit-log-service';
import { moduleService } from '@/services/module-service'; import { moduleService } from '@/services/module-service';
import type { AuditLog } from '@/types/audit-log'; import type { AuditLog } from '@/types/audit-log';
import { useAppTheme } from '@/hooks/useAppTheme';
import { PrimaryButton } from '@/components/shared';
import { useAppSelector } from '@/hooks/redux-hooks'; import { useAppSelector } from '@/hooks/redux-hooks';
// Helper function to format date // Helper function to format date
@ -58,6 +60,7 @@ const getStatusColor = (status: number | null): string => {
}; };
const AuditLogs = (): ReactElement => { const AuditLogs = (): ReactElement => {
const { primaryColor } = useAppTheme();
const roles = useAppSelector((state) => state.auth.roles); const roles = useAppSelector((state) => state.auth.roles);
const tenantId = useAppSelector((state) => state.auth.tenantId); const tenantId = useAppSelector((state) => state.auth.tenantId);
const isTenantAdmin = roles?.includes('tenant_admin'); const isTenantAdmin = roles?.includes('tenant_admin');
@ -309,7 +312,8 @@ const AuditLogs = (): ReactElement => {
<button <button
type="button" type="button"
onClick={() => handleViewAuditLog(log.id)} 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 View
</button> </button>
@ -333,7 +337,8 @@ const AuditLogs = (): ReactElement => {
<button <button
type="button" type="button"
onClick={() => handleViewAuditLog(log.id)} 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 View
</button> </button>
@ -396,7 +401,15 @@ const AuditLogs = (): ReactElement => {
placeholder="Search logs & metadata..." placeholder="Search logs & metadata..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} 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> </div>
@ -472,14 +485,14 @@ const AuditLogs = (): ReactElement => {
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isTenantAdmin && ( {isTenantAdmin && (
<button <PrimaryButton
type="button"
onClick={handleExport} 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" /> <Download className="w-3.5 h-3.5" />
<span>Export</span> <span>Export</span>
</button> </PrimaryButton>
)} )}
</div> </div>
</div> </div>
@ -522,7 +535,8 @@ const AuditLogs = (): ReactElement => {
setSearch(''); setSearch('');
setCurrentPage(1); setCurrentPage(1);
}} }}
className="text-xs text-[#ef4444] hover:underline" className="text-xs hover:underline decoration-offset-2"
style={{ color: primaryColor }}
> >
Clear all filters Clear all filters
</button> </button>

View File

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

View File

@ -11,6 +11,7 @@ import { QuickActions } from "@/features/dashboard/components/QuickActions";
import { RecentActivity } from "@/features/dashboard/components/RecentActivity"; import { RecentActivity } from "@/features/dashboard/components/RecentActivity";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useAppTheme } from "@/hooks/useAppTheme";
import { workflowService } from "@/services/workflow-service"; import { workflowService } from "@/services/workflow-service";
import { dashboardService, type TenantDashboardStats } from "@/services/dashboard-service"; import { dashboardService, type TenantDashboardStats } from "@/services/dashboard-service";
import type { WorkflowTask } from "@/types/workflow"; import type { WorkflowTask } from "@/types/workflow";
@ -32,11 +33,15 @@ const StatCard = ({
label, label,
badge badge
}: StatCardProps): ReactElement => { }: StatCardProps): ReactElement => {
const { primaryColor } = useAppTheme();
return ( return (
<div className="relative group h-full"> <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"> <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 */} {/* 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="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"> <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 TaskCard = ({ task }: { task: WorkflowTask }) => {
const { primaryColor } = useAppTheme();
const navigate = useNavigate(); const navigate = useNavigate();
const formatDeadline = (dueDate: string) => { const formatDeadline = (dueDate: string) => {
const now = new Date(); const now = new Date();
@ -118,7 +124,10 @@ const TaskCard = ({ task }: { task: WorkflowTask }) => {
> >
View View
</button> </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 Complete
</button> </button>
</div> </div>
@ -190,6 +199,7 @@ const CAPASummaryChart = () => {
*/ */
const Dashboard = (): ReactElement => { const Dashboard = (): ReactElement => {
const { primaryColor } = useAppTheme();
const navigate = useNavigate(); const navigate = useNavigate();
const [tasks, setTasks] = useState<WorkflowTask[]>([]); const [tasks, setTasks] = useState<WorkflowTask[]>([]);
const [stats, setStats] = useState<TenantDashboardStats | null>(null); 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> <h2 className="text-[16px] font-bold text-[#111827] tracking-tight">My Tasks</h2>
<button <button
onClick={() => navigate('/tenant/workflows/tasks')} 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 View all
</button> </button>

View File

@ -12,6 +12,7 @@ import { documentService } from "@/services/document-service";
import { moduleService } from "@/services/module-service"; import { moduleService } from "@/services/module-service";
import type { DocumentCategory, DocumentSummary } from "@/types/document"; import type { DocumentCategory, DocumentSummary } from "@/types/document";
import type { Module } from "@/types/module"; import type { Module } from "@/types/module";
import { useAppTheme } from "@/hooks/useAppTheme";
import { Plus, Search } from "lucide-react"; import { Plus, Search } from "lucide-react";
const formatDate = (value?: string | null): string => { const formatDate = (value?: string | null): string => {
@ -30,6 +31,7 @@ const toLabel = (value: string): string =>
.join(" "); .join(" ");
const Documents = (): ReactElement => { const Documents = (): ReactElement => {
const { primaryColor } = useAppTheme();
const navigate = useNavigate(); const navigate = useNavigate();
const [documents, setDocuments] = useState<DocumentSummary[]>([]); const [documents, setDocuments] = useState<DocumentSummary[]>([]);
const [categories, setCategories] = useState<DocumentCategory[]>([]); const [categories, setCategories] = useState<DocumentCategory[]>([]);
@ -109,7 +111,8 @@ const Documents = (): ReactElement => {
render: (doc) => ( render: (doc) => (
<button <button
type="button" type="button"
className="text-[#084cc8] hover:underline" className="hover:underline transition-colors"
style={{ color: primaryColor }}
onClick={() => navigate(`/tenant/documents/${doc.id}`)} onClick={() => navigate(`/tenant/documents/${doc.id}`)}
> >
{doc.document_number} {doc.document_number}
@ -137,7 +140,10 @@ const Documents = (): ReactElement => {
key: "status", key: "status",
label: "Status", label: "Status",
render: (doc) => ( 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)} {toLabel(doc.status)}
</span> </span>
), ),
@ -169,7 +175,8 @@ const Documents = (): ReactElement => {
render: (doc) => ( render: (doc) => (
<button <button
type="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) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
navigate(`/tenant/documents/edit/${doc.id}`); navigate(`/tenant/documents/edit/${doc.id}`);
@ -225,7 +232,20 @@ const Documents = (): ReactElement => {
setCurrentPage(1); setCurrentPage(1);
}} }}
placeholder="Search by name, ID..." 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> </div>

View File

@ -26,9 +26,11 @@ import {
RefreshCw, RefreshCw,
ZoomIn, ZoomIn,
ZoomOut, ZoomOut,
Plus,
} from "lucide-react"; } from "lucide-react";
import { Layout } from "@/components/layout/Layout"; 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 { cn } from "@/lib/utils";
import fileAttachmentService, { import fileAttachmentService, {
type FileAttachment, type FileAttachment,
@ -78,19 +80,53 @@ function copyToClipboard(text: string): void {
// Preview component // Preview component
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement { 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 [loading, setLoading] = useState(true);
const [err, setErr] = useState(false); const [err, setErr] = useState(false);
const [zoom, setZoom] = useState(1); const [zoom, setZoom] = useState(1);
const [extractedHtml, setExtractedHtml] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
setLoading(true); const loadPreview = async () => {
setErr(false); setLoading(true);
fileAttachmentService setErr(false);
.getPreviewUrl(file.id) setExtractedHtml(null);
.then((url) => setPreviewUrl(url)) setPreviewUrl(undefined);
.catch(() => setErr(true))
.finally(() => setLoading(false)); 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 () => { return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl); if (previewUrl) URL.revokeObjectURL(previewUrl);
@ -99,7 +135,6 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
}, [file.id]); }, [file.id]);
const isImage = file.mime_type?.startsWith("image/"); const isImage = file.mime_type?.startsWith("image/");
const isPdf = file.mime_type === "application/pdf";
if (loading) { if (loading) {
return ( return (
@ -109,12 +144,18 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
); );
} }
if (err || !previewUrl) { if (err || (!previewUrl && !extractedHtml)) {
return ( return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-[#9aa6b2]"> <div className="flex-1 flex flex-col items-center justify-center gap-3 text-[#9aa6b2]">
<FileText className="w-16 h-16 text-gray-200" /> <FileText className="w-16 h-16 text-gray-200" />
<p className="text-sm">Preview not available</p> <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> </div>
); );
} }
@ -154,17 +195,30 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
className="max-w-full rounded-lg shadow transition-transform duration-200" className="max-w-full rounded-lg shadow transition-transform duration-200"
/> />
</div> </div>
) : isPdf || file.mime_type?.startsWith("text/") ? ( ) : previewUrl ? (
<iframe <iframe
src={previewUrl} src={previewUrl}
title={file.original_name} 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"> <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" /> <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> <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>
)} )}
</div> </div>
@ -233,7 +287,8 @@ const FileView = (): ReactElement => {
const [editingMetadata, setEditingMetadata] = useState(false); const [editingMetadata, setEditingMetadata] = useState(false);
const [copiedChecksum, setCopiedChecksum] = useState(false); const [copiedChecksum, setCopiedChecksum] = useState(false);
const [copiedPath, setCopiedPath] = 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 // Metadata edit form
const [draftDescription, setDraftDescription] = useState(""); const [draftDescription, setDraftDescription] = useState("");
@ -352,7 +407,14 @@ const FileView = (): ReactElement => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <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" 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" /> <Share2 className="w-3.5 h-3.5" />
@ -603,11 +665,24 @@ const FileView = (): ReactElement => {
</div> </div>
</div> </div>
<FileShareModal {isShareModalOpen && file && (
isOpen={showShareModal} <FileShareModal
onClose={() => setShowShareModal(false)} isOpen={isShareModalOpen}
file={file} onClose={() => setIsShareModalOpen(false)}
/> file={file}
/>
)}
{isVersionModalOpen && file && (
<FileVersionUploadModal
isOpen={isVersionModalOpen}
onClose={() => setIsVersionModalOpen(false)}
file={file}
onUploaded={(newFile) => {
navigate(`/tenant/files/${newFile.id}`);
}}
/>
)}
</Layout> </Layout>
); );
}; };

View File

@ -19,23 +19,21 @@ import {
Image, Image,
FileArchive, FileArchive,
Table as TableIcon, Table as TableIcon,
MoreHorizontal,
Download,
Eye,
Pencil,
Trash2,
ChevronDown, ChevronDown,
} from "lucide-react"; } from "lucide-react";
import { Layout } from "@/components/layout/Layout"; import { Layout } from "@/components/layout/Layout";
import { Pagination } from "@/components/shared"; import { Pagination } from "@/components/shared";
import { DeleteConfirmationModal } from "@/components/shared/DeleteConfirmationModal";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import fileAttachmentService, { import fileAttachmentService, {
type FileAttachment, type FileAttachment,
type CategoriesFilterOptions, type CategoriesFilterOptions,
} from "@/services/file-attachment-service"; } from "@/services/file-attachment-service";
import { moduleService } from "@/services/module-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 { FileUploadModal } from "@/components/shared/FileUploadModal";
import { useAppTheme } from "@/hooks/useAppTheme";
import { PrimaryButton } from "@/components/shared";
import type { RootState } from "@/store/store"; 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]}`; 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?.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 (mime === "application/pdf") return <FileText className="w-4 h-4 text-red-500" />;
if ( if (
@ -72,7 +70,7 @@ function getFileIcon(mime: string, name: string): ReactElement {
return <TableIcon className="w-4 h-4 text-green-600" />; return <TableIcon className="w-4 h-4 text-green-600" />;
if (mime?.includes("zip") || mime?.includes("archive")) if (mime?.includes("zip") || mime?.includes("archive"))
return <FileArchive className="w-4 h-4 text-yellow-500" />; 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> = { const categoryColors: Record<string, string> = {
@ -142,6 +140,7 @@ function FilterPill({
}): ReactElement { }): ReactElement {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const selected = options.find((o) => o.value === value); const selected = options.find((o) => o.value === value);
const { primaryColor } = useAppTheme();
return ( return (
<div className="relative"> <div className="relative">
@ -150,12 +149,13 @@ function FilterPill({
className={cn( className={cn(
"inline-flex items-center gap-1.5 h-9 px-3 rounded-lg text-sm font-medium border transition-colors", "inline-flex items-center gap-1.5 h-9 px-3 rounded-lg text-sm font-medium border transition-colors",
value value
? "border-[#084cc8] bg-[#084cc8]/5 text-[#084cc8]" ? "bg-opacity-5"
: "border-[rgba(0,0,0,0.1)] bg-white text-[#475569] hover:border-[#084cc8]/30" : "border-[rgba(0,0,0,0.1)] bg-white text-[#475569]"
)} )}
style={value ? { borderColor: primaryColor, backgroundColor: `${primaryColor}10`, color: primaryColor } : {}}
> >
{label} {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" /> <ChevronDown className="w-3.5 h-3.5 opacity-60" />
</button> </button>
{open && ( {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 // Main component
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
const FilesList = (): ReactElement => { const FilesList = (): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
const { primaryColor } = useAppTheme();
const permissions = useSelector((state: RootState) => state.auth.permissions); const permissions = useSelector((state: RootState) => state.auth.permissions);
// Permission checks // Permission checks
@ -292,7 +226,7 @@ const FilesList = (): ReactElement => {
// Pagination // Pagination
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [limit] = useState(10); const [limit, setLimit] = useState(10);
const offset = (currentPage - 1) * limit; const offset = (currentPage - 1) * limit;
const totalPages = Math.max(1, Math.ceil(total / limit)); const totalPages = Math.max(1, Math.ceil(total / limit));
@ -303,7 +237,9 @@ const FilesList = (): ReactElement => {
const [showUpload, setShowUpload] = useState(false); const [showUpload, setShowUpload] = useState(false);
// Deleting // 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 ── // ── Load categories ──
useEffect(() => { useEffect(() => {
@ -352,19 +288,26 @@ const FilesList = (): ReactElement => {
fileAttachmentService.download(file.id, file.original_name).catch(() => {}); fileAttachmentService.download(file.id, file.original_name).catch(() => {});
}; };
const handleDelete = async (id: string) => { const handleDelete = async () => {
if (!window.confirm("Delete this file?")) return; if (!fileToDelete) return;
setDeletingId(id); setIsDeleting(true);
try { try {
await fileAttachmentService.delete(id); await fileAttachmentService.delete(fileToDelete.id, isHardDelete);
setFileToDelete(null);
setIsHardDelete(false);
await loadFiles(); await loadFiles();
} catch { } catch (err: any) {
// silence alert(err?.response?.data?.error?.message || "Failed to delete file");
} finally { } finally {
setDeletingId(null); setIsDeleting(false);
} }
}; };
const openDeleteConfirm = (file: FileAttachment) => {
setFileToDelete({ id: file.id, name: file.original_name });
setIsHardDelete(false);
};
const clearFilters = () => { const clearFilters = () => {
setSearch(""); setSearch("");
setCategoryFilter(null); setCategoryFilter(null);
@ -378,22 +321,22 @@ const FilesList = (): ReactElement => {
return ( return (
<Layout <Layout
currentPage="File Attachment Services" currentPage="File Attachment Services"
breadcrumbs={[ // breadcrumbs={[
{ label: "File Attachment Services" }, // { label: "File Attachment Services" },
{ label: "File List" }, // { label: "File List" },
]} // ]}
pageHeader={{ pageHeader={{
title: "Files List", title: "Files List",
description: "Manage controlled documents across their entire lifecycle.", description: "Manage controlled documents across their entire lifecycle.",
action: canCreate ? ( action: canCreate ? (
<button <PrimaryButton
id="upload-new-file-btn" id="upload-new-file-btn"
onClick={() => setShowUpload(true)} 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 className="w-3.5 h-3.5" />
Upload New File Upload New File
</button> </PrimaryButton>
) : null, ) : null,
}} }}
> >
@ -410,7 +353,20 @@ const FilesList = (): ReactElement => {
value={search} value={search}
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }} onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
placeholder="Search by name, ID..." 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> </div>
@ -493,7 +449,8 @@ const FilesList = (): ReactElement => {
{canCreate && ( {canCreate && (
<button <button
onClick={() => setShowUpload(true)} 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 Upload your first file
</button> </button>
@ -511,12 +468,16 @@ const FilesList = (): ReactElement => {
<td className="px-4 py-3 min-w-[200px]"> <td className="px-4 py-3 min-w-[200px]">
<button <button
onClick={() => navigate(`/tenant/files/${file.id}`)} 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"> <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> </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} {file.original_name}
</span> </span>
</button> </button>
@ -594,13 +555,11 @@ const FilesList = (): ReactElement => {
{/* Actions */} {/* Actions */}
<td className="px-4 py-3"> <td className="px-4 py-3">
<ActionMenu <ActionDropdown
onView={() => navigate(`/tenant/files/${file.id}`)} onView={() => navigate(`/tenant/files/${file.id}`)}
onDownload={() => handleDownload(file)} onDownload={() => handleDownload(file)}
onEdit={() => navigate(`/tenant/files/${file.id}`)} onEdit={canUpdate ? () => navigate(`/tenant/files/${file.id}`) : undefined}
onDelete={() => handleDelete(file.id)} onDelete={canDelete ? () => openDeleteConfirm(file) : undefined}
canEdit={canUpdate}
canDelete={canDelete}
/> />
</td> </td>
</tr> </tr>
@ -618,7 +577,7 @@ const FilesList = (): ReactElement => {
totalItems={total} totalItems={total}
limit={limit} limit={limit}
onPageChange={setCurrentPage} onPageChange={setCurrentPage}
onLimitChange={() => {}} onLimitChange={setLimit}
/> />
)} )}
</div> </div>
@ -634,6 +593,34 @@ const FilesList = (): ReactElement => {
}} }}
isTenantAdmin={canCreate} 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> </Layout>
); );
}; };

View File

@ -9,6 +9,7 @@ import {
import { useAppSelector } from '@/hooks/redux-hooks'; import { useAppSelector } from '@/hooks/redux-hooks';
import { moduleService } from '@/services/module-service'; import { moduleService } from '@/services/module-service';
import type { MyModule } from '@/types/module'; import type { MyModule } from '@/types/module';
import { useAppTheme } from '@/hooks/useAppTheme';
// Helper function to get status badge variant // Helper function to get status badge variant
const getStatusVariant = (status: string | null): 'success' | 'failure' | 'process' => { const getStatusVariant = (status: string | null): 'success' | 'failure' | 'process' => {
@ -26,6 +27,7 @@ const getStatusVariant = (status: string | null): 'success' | 'failure' | 'proce
}; };
const Modules = (): ReactElement => { const Modules = (): ReactElement => {
const { primaryColor } = useAppTheme();
const { roles, tenantId } = useAppSelector((state) => state.auth); const { roles, tenantId } = useAppSelector((state) => state.auth);
const [modules, setModules] = useState<MyModule[]>([]); const [modules, setModules] = useState<MyModule[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
@ -131,7 +133,8 @@ const Modules = (): ReactElement => {
<button <button
type="button" type="button"
onClick={() => handleLaunchModule(module.id)} 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 Launch
</button> </button>
@ -172,7 +175,8 @@ const Modules = (): ReactElement => {
<button <button
type="button" type="button"
onClick={() => handleLaunchModule(module.id)} 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 Launch
</button> </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 { moduleService } from "@/services/module-service";
import type { WorkflowTask, WorkflowTaskCounts } from "@/types/workflow"; import type { WorkflowTask, WorkflowTaskCounts } from "@/types/workflow";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAppTheme } from "@/hooks/useAppTheme";
import { Inbox, Clock, Calendar, CheckCircle2, RotateCcw } from "lucide-react"; import { Inbox, Clock, Calendar, CheckCircle2, RotateCcw } from "lucide-react";
const formatDate = (value?: string | null): string => { 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="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" /> <Icon className="w-6 h-6 text-white" />
</div> </div>
<div> <div>
@ -37,6 +41,7 @@ const StatCard = ({ icon: Icon, label, value, color }: { icon: any, label: strin
); );
const Tasks = (): ReactElement => { const Tasks = (): ReactElement => {
const { primaryColor } = useAppTheme();
const navigate = useNavigate(); const navigate = useNavigate();
const [tasks, setTasks] = useState<WorkflowTask[]>([]); const [tasks, setTasks] = useState<WorkflowTask[]>([]);
const [counts, setCounts] = useState<WorkflowTaskCounts | null>(null); const [counts, setCounts] = useState<WorkflowTaskCounts | null>(null);
@ -124,7 +129,10 @@ const Tasks = (): ReactElement => {
label: "Step", label: "Step",
render: (task) => ( render: (task) => (
<div className="flex items-center gap-2"> <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} {task.step.name}
</span> </span>
</div> </div>
@ -176,7 +184,8 @@ const Tasks = (): ReactElement => {
navigate(`/tenant/documents/${task.entity.id}`); 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 View
</button> </button>
@ -201,7 +210,7 @@ const Tasks = (): ReactElement => {
icon={Inbox} icon={Inbox}
label="Pending Tasks" label="Pending Tasks"
value={counts?.pending || 0} value={counts?.pending || 0}
color="bg-blue-600" style={{ backgroundColor: primaryColor }}
/> />
<StatCard <StatCard
icon={Clock} icon={Clock}

View File

@ -19,6 +19,7 @@ import { userService } from "@/services/user-service";
import type { User } from "@/types/user"; import type { User } from "@/types/user";
import { showToast } from "@/utils/toast"; import { showToast } from "@/utils/toast";
import { usePermissions } from "@/hooks/usePermissions"; import { usePermissions } from "@/hooks/usePermissions";
import { useAppTheme } from "@/hooks/useAppTheme";
// Helper function to get user initials // Helper function to get user initials
const getUserInitials = (firstName: string, lastName: string): string => { const getUserInitials = (firstName: string, lastName: string): string => {
@ -56,6 +57,7 @@ const getStatusVariant = (
}; };
const Users = (): ReactElement => { const Users = (): ReactElement => {
const { primaryColor } = useAppTheme();
const { canCreate, canUpdate const { canCreate, canUpdate
// , canDelete // , canDelete
} = usePermissions(); } = usePermissions();
@ -268,7 +270,8 @@ const Users = (): ReactElement => {
user.role_module_combinations.map((combo, idx) => ( user.role_module_combinations.map((combo, idx) => (
<span <span
key={`${combo.role_id}-${combo.module_id || 'global'}-${idx}`} 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"} title={combo.module_name ? `Role for ${combo.module_name}` : "Global Role"}
> >
{combo.role_name} {combo.module_name && `(${combo.module_name})`} {combo.role_name} {combo.module_name && `(${combo.module_name})`}
@ -278,7 +281,8 @@ const Users = (): ReactElement => {
user.roles.map((role) => ( user.roles.map((role) => (
<span <span
key={role.id} 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} {role.name}
</span> </span>

View File

@ -19,6 +19,7 @@ import type { DocumentDetail, DocumentVersion } from "@/types/document";
import type { WorkflowInstance } from "@/types/workflow"; import type { WorkflowInstance } from "@/types/workflow";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { showToast } from "@/utils/toast"; import { showToast } from "@/utils/toast";
import { useAppTheme } from "@/hooks/useAppTheme";
import { Paperclip, Plus, User } from "lucide-react"; import { Paperclip, Plus, User } from "lucide-react";
const formatDateTime = (value?: string | null): string => { const formatDateTime = (value?: string | null): string => {
@ -52,6 +53,7 @@ const ACTION_LABELS: Record<DocumentAction, string> = {
}; };
const ViewDocument = (): ReactElement => { const ViewDocument = (): ReactElement => {
const { primaryColor } = useAppTheme();
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams(); const { id } = useParams();
const [document, setDocument] = useState<DocumentDetail | null>(null); const [document, setDocument] = useState<DocumentDetail | null>(null);
@ -504,7 +506,8 @@ const ViewDocument = (): ReactElement => {
</button> </button>
<button <button
type="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")} onClick={() => void openActionModal("submit")}
> >
{ACTION_LABELS["submit"]} {ACTION_LABELS["submit"]}
@ -515,7 +518,8 @@ const ViewDocument = (): ReactElement => {
<> <>
<button <button
type="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()} onClick={() => void openWorkflowTracker()}
> >
Workflow Status Workflow Status
@ -540,7 +544,8 @@ const ViewDocument = (): ReactElement => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="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")} onClick={() => void openActionModal("effective")}
> >
{ACTION_LABELS["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"> <span className="px-2 py-1 rounded-full bg-amber-100 text-amber-700 text-[11px] font-medium">
{document?.document_type || "-"} {document?.document_type || "-"}
</span> </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 || "-"} v{document?.current_version || "-"}
</span> </span>
{document?.module_name && ( {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"> <div className="flex items-center gap-5 border-b border-[rgba(0,0,0,0.08)] mb-4">
<button <button
type="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")} onClick={() => setActiveTab("overview")}
> >
Overview Overview
</button> </button>
<button <button
type="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")} onClick={() => setActiveTab("version-history")}
> >
Version History Version History
</button> </button>
<button <button
type="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")} onClick={() => setActiveTab("workflow-history")}
> >
Workflow History Workflow History
@ -701,7 +721,8 @@ const ViewDocument = (): ReactElement => {
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
type="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={() => { onClick={() => {
setShowNewVersionForm((prev) => !prev); setShowNewVersionForm((prev) => !prev);
setNewVersionContent(document.content || ""); setNewVersionContent(document.content || "");

View File

@ -6,10 +6,10 @@ const WorkflowDefinationPage = (): ReactElement => {
return ( return (
<Layout <Layout
currentPage="Workflow Definitions" currentPage="Workflow Definitions"
breadcrumbs={[ // breadcrumbs={[
{ label: "Platform", path: "/tenant" }, // // { label: "Platform", path: "/tenant" },
{ label: "Workflow Definitions" }, // { label: "Workflow Definitions" },
]} // ]}
> >
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between"> <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 CreateDocument = lazy(() => import("@/pages/tenant/CreateDocument"));
const EditDocument = lazy(() => import("@/pages/tenant/EditDocument")); const EditDocument = lazy(() => import("@/pages/tenant/EditDocument"));
const ViewDocument = lazy(() => import("@/pages/tenant/ViewDocument")); const ViewDocument = lazy(() => import("@/pages/tenant/ViewDocument"));
const DocumentCategories = lazy(() => import("@/pages/tenant/DocumentCategories")); const DocumentCategories = lazy(
const DocumentsDueForReview = lazy(() => import("@/pages/tenant/DocumentsDueForReview")); () => import("@/pages/tenant/DocumentCategories"),
);
const DocumentsDueForReview = lazy(
() => import("@/pages/tenant/DocumentsDueForReview"),
);
const Tasks = lazy(() => import("@/pages/tenant/Tasks")); 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 Notifications = lazy(() => import("@/pages/tenant/Notifications"));
const FilesList = lazy(() => import("@/pages/tenant/FilesList")); const FilesList = lazy(() => import("@/pages/tenant/FilesList"));
const FileView = lazy(() => import("@/pages/tenant/FileView")); const FileView = lazy(() => import("@/pages/tenant/FileView"));
const StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard"));
// Loading fallback component // Loading fallback component
const RouteLoader = (): ReactElement => ( const RouteLoader = (): ReactElement => (
@ -140,4 +147,8 @@ export const tenantAdminRoutes: RouteConfig[] = [
path: "/tenant/files/:id", path: "/tenant/files/:id",
element: <LazyRoute component={FileView} />, element: <LazyRoute component={FileView} />,
}, },
{
path: "/tenant/files/storage-dashboard",
element: <LazyRoute component={StorageDashboard} />,
},
]; ];

View File

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