Compare commits

..

No commits in common. "f51e0af9c8c1180bd29538832806bfdb33e793c4" and "9647e3e632e3a1c41e9a175a77b8a3ac474b72b8" have entirely different histories.

31 changed files with 634 additions and 1579 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 { useAppTheme } from "@/hooks/useAppTheme"; import { useTenantTheme } from "@/hooks/useTenantTheme";
import { AuthenticatedImage } from "@/components/shared"; import { AuthenticatedImage } from "@/components/shared";
interface MenuItem { interface MenuItem {
@ -109,11 +109,6 @@ 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" },
}, },
@ -190,15 +185,15 @@ const GroupMenuItem = ({
item, item,
childrenItems, childrenItems,
location, location,
primaryColor, isSuperAdmin,
secondaryColor, theme,
onClose, onClose,
}: { }: {
item: MenuItem; item: MenuItem;
childrenItems: any[]; childrenItems: any[];
location: any; location: any;
primaryColor: string; isSuperAdmin: boolean;
secondaryColor: string; theme: any;
onClose: () => void; onClose: () => void;
}) => { }) => {
const isChildActive = (path: string) => { const isChildActive = (path: string) => {
@ -210,14 +205,6 @@ 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}/`)
); );
@ -246,8 +233,14 @@ const GroupMenuItem = ({
style={ style={
isAnyChildActive isAnyChildActive
? { ? {
backgroundColor: primaryColor, backgroundColor:
color: secondaryColor, !isSuperAdmin && theme?.primary_color
? theme.primary_color
: "#112868",
color:
!isSuperAdmin && theme?.secondary_color
? theme.secondary_color
: "#23dce1",
} }
: undefined : undefined
} }
@ -290,7 +283,10 @@ const GroupMenuItem = ({
style={ style={
isActive isActive
? { ? {
color: primaryColor, color:
!isSuperAdmin && theme?.primary_color
? theme.primary_color
: "#112868",
} }
: undefined : undefined
} }
@ -308,9 +304,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 = () => {
@ -361,9 +357,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 = (
@ -465,8 +461,8 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
item={item} item={item}
childrenItems={children} childrenItems={children}
location={location} location={location}
primaryColor={primaryColor} isSuperAdmin={isSuperAdmin}
secondaryColor={secondaryColor} theme={theme}
onClose={onClose} onClose={onClose}
/> />
); );
@ -498,8 +494,14 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
style={ style={
isActive isActive
? { ? {
backgroundColor: primaryColor, backgroundColor:
color: secondaryColor, !isSuperAdmin && theme?.primary_color
? theme.primary_color
: "#112868",
color:
!isSuperAdmin && theme?.secondary_color
? theme.secondary_color
: "#23dce1",
} }
: undefined : undefined
} }
@ -543,7 +545,10 @@ 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: primaryColor, backgroundColor:
!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} />
@ -556,7 +561,10 @@ 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: accentColor color:
!isSuperAdmin && theme?.accent_color
? theme.accent_color
: "#084cc8",
}} }}
> >
{roleName} {roleName}
@ -617,10 +625,7 @@ 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 <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]">
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>
} }
@ -630,7 +635,10 @@ 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: primaryColor, backgroundColor:
!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} />
@ -643,7 +651,10 @@ 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: accentColor color:
!isSuperAdmin && theme?.accent_color
? theme.accent_color
: "#084cc8",
}} }}
> >
{roleName} {roleName}

View File

@ -1,9 +1,8 @@
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, Download } from 'lucide-react'; import { MoreVertical, Eye, Edit, Trash2, Users, BarChart3 } 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;
@ -18,7 +17,6 @@ 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;
@ -30,12 +28,10 @@ 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);
@ -71,22 +67,14 @@ 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 = ( const dropdownHeight = actions ? actions.length * 32 + 16 : 120; // Approximate height based on actions
(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 || onScorecards || onDownload || onContacts) ? 140 : 100; // Wider for longer labels const width = actions ? 140 : 76; // Wider for custom actions
if (shouldOpenUp) { if (shouldOpenUp) {
// Position above the button // Position above the button
@ -137,25 +125,12 @@ export const ActionDropdown = ({
ref={buttonRef} ref={buttonRef}
type="button" type="button"
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center w-7 h-7 rounded-full border border-[rgba(0,0,0,0.08)] transition-colors cursor-pointer" className={cn(
style={isOpen 'flex items-center justify-center w-7 h-7 rounded-full border border-[rgba(0,0,0,0.08)] transition-colors cursor-pointer',
? { backgroundColor: primaryColor, color: 'white', borderColor: primaryColor } isOpen
: { backgroundColor: 'white', color: '#0f1724' } ? 'bg-[#084cc8] text-white'
} : '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}
> >
@ -194,9 +169,9 @@ export const ActionDropdown = ({
<button <button
type="button" type="button"
onClick={() => handleAction(onView)} onClick={() => handleAction(onView)}
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" 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"
> >
<Eye className="w-3.5 h-3.5 shrink-0" /> <Eye className="w-3.5 h-3.5" />
<span>View</span> <span>View</span>
</button> </button>
)} )}
@ -204,9 +179,9 @@ export const ActionDropdown = ({
<button <button
type="button" type="button"
onClick={() => handleAction(onEdit)} onClick={() => handleAction(onEdit)}
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" 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"
> >
<Edit className="w-3.5 h-3.5 shrink-0" /> <Edit className="w-3 h-3" />
<span>Edit</span> <span>Edit</span>
</button> </button>
)} )}
@ -214,9 +189,9 @@ export const ActionDropdown = ({
<button <button
type="button" type="button"
onClick={() => handleAction(onDelete)} onClick={() => handleAction(onDelete)}
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" className="flex items-center gap-2 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
> >
<Trash2 className="w-3.5 h-3.5 shrink-0" /> <Trash2 className="w-3.5 h-3.5" />
<span>Delete</span> <span>Delete</span>
</button> </button>
)} )}
@ -224,9 +199,9 @@ export const ActionDropdown = ({
<button <button
type="button" type="button"
onClick={() => handleAction(onContacts)} onClick={() => handleAction(onContacts)}
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" 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"
> >
<Users className="w-3.5 h-3.5 shrink-0" /> <Users className="w-3.5 h-3.5" />
<span>Contacts</span> <span>Contacts</span>
</button> </button>
)} )}
@ -234,22 +209,12 @@ export const ActionDropdown = ({
<button <button
type="button" type="button"
onClick={() => handleAction(onScorecards)} onClick={() => handleAction(onScorecards)}
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" 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"
> >
<BarChart3 className="w-3.5 h-3.5 shrink-0" /> <BarChart3 className="w-3.5 h-3.5" />
<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,7 +12,6 @@ interface DeleteConfirmationModalProps {
message: string; message: string;
itemName?: string; itemName?: string;
isLoading?: boolean; isLoading?: boolean;
children?: React.ReactNode;
} }
export const DeleteConfirmationModal = ({ export const DeleteConfirmationModal = ({
@ -23,7 +22,6 @@ 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);
@ -67,10 +65,10 @@ export const DeleteConfirmationModal = ({
if (!isOpen) return null; if (!isOpen) return null;
const modalContent = ( const modalContent = (
<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 className="fixed inset-0 z-[200] 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-[301]" 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]"
> >
{/* 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)]">
@ -102,7 +100,6 @@ 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,7 +13,6 @@ 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;
@ -31,9 +30,7 @@ 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; id: string } | null>(null); const [shareData, setShareData] = useState<{ url: string; token: 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 () => {
@ -48,7 +45,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, id: res.data.id }); setShareData({ url: fullUrl, token: res.data.share_token });
} catch (error) { } catch (error) {
console.error("Failed to share:", error); console.error("Failed to share:", error);
} finally { } finally {
@ -56,22 +53,6 @@ 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 {
@ -109,7 +90,6 @@ 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 ? (
@ -224,8 +204,7 @@ export const FileShareModal: React.FC<FileShareModalProps> = ({
</div> </div>
</div> </div>
<div className="pt-2 flex flex-col gap-2"> <div className="pt-2 flex gap-2">
<div className="flex gap-2">
<button <button
onClick={() => setShareData(null)} 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" 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"
@ -240,19 +219,6 @@ export const FileShareModal: React.FC<FileShareModalProps> = ({
Test Link Test Link
</button> </button>
</div> </div>
<button
onClick={() => setShowRevokeConfirm(true)}
disabled={isRevoking}
className="w-full h-10 border border-red-100 bg-red-50/50 hover:bg-red-50 rounded-xl text-xs font-bold text-red-600 transition-all flex items-center justify-center gap-2"
>
{isRevoking ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
"Revoke Link (Stop Sharing)"
)}
</button>
</div>
</div> </div>
)} )}
@ -263,15 +229,6 @@ 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,6 +14,7 @@ import {
type ReactElement, type ReactElement,
useEffect, useEffect,
} from "react"; } from "react";
import { createPortal } from "react-dom";
import { import {
X, X,
Upload, Upload,
@ -28,14 +29,6 @@ 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";
@ -335,44 +328,27 @@ 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 footer = ( const content = (
<> <div className="fixed inset-0 z-[200] flex items-center justify-center bg-[rgba(15,23,42,0.55)] backdrop-blur-sm p-4">
<SecondaryButton <div className="bg-white rounded-2xl shadow-[0px_25px_50px_-12px_rgba(0,0,0,0.25)] w-full max-w-[500px] max-h-[92vh] flex flex-col">
{/* Header */}
<div className="flex items-start justify-between px-6 pt-6 pb-4 border-b border-[rgba(0,0,0,0.08)] shrink-0">
<div>
<h2 className="text-[17px] font-semibold text-[#0e1b2a]">Upload New File</h2>
<p className="text-sm text-[#9aa6b2] mt-0.5">Attach files via File Attachment Service</p>
</div>
<button
onClick={handleClose} onClick={handleClose}
disabled={isUploading} disabled={isUploading}
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors text-[#6b7280] disabled:opacity-40"
> >
Cancel <X className="w-4 h-4" />
</SecondaryButton> </button>
<PrimaryButton </div>
onClick={handleUpload}
disabled={isUploading || files.length === 0 || validCount === 0} {/* Scrollable body */}
className="flex items-center gap-2" <div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 space-y-5">
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Uploading ({doneCount}/{validCount})
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload {validCount > 0 ? `(${validCount})` : ""}
</>
)}
</PrimaryButton>
</>
);
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Upload New File"
description="Attach files via File Attachment Service"
maxWidth="md"
footer={footer}
>
<div className="px-6 py-5 space-y-5">
{/* Drop Zone */} {/* Drop Zone */}
<div> <div>
<p className="text-xs font-semibold text-[#0e1b2a] mb-2 uppercase tracking-wide">Attach Files</p> <p className="text-xs font-semibold text-[#0e1b2a] mb-2 uppercase tracking-wide">Attach Files</p>
@ -392,7 +368,7 @@ export const FileUploadModal = ({
<div className="w-10 h-10 rounded-full bg-[#084cc8]/10 flex items-center justify-center"> <div className="w-10 h-10 rounded-full bg-[#084cc8]/10 flex items-center justify-center">
<Upload className="w-5 h-5 text-[#084cc8]" /> <Upload className="w-5 h-5 text-[#084cc8]" />
</div> </div>
<div className="text-center text-gray-800"> <div className="text-center">
<p className="text-sm font-medium text-[#0e1b2a]">Click to upload or drag and drop</p> <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"> <p className="text-xs text-[#9aa6b2] mt-0.5">
Attach supporting source files via File Attachment Service Attach supporting source files via File Attachment Service
@ -415,8 +391,8 @@ export const FileUploadModal = ({
onClick={() => inputRef.current?.click()} 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" 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 text-white"> <div className="w-7 h-7 rounded-full bg-[#084cc8] flex items-center justify-center">
<Upload className="w-3.5 h-3.5" /> <Upload className="w-3.5 h-3.5 text-white" />
</div> </div>
<span className="text-sm font-semibold text-[#084cc8]">Add Files</span> <span className="text-sm font-semibold text-[#084cc8]">Add Files</span>
</div> </div>
@ -499,43 +475,53 @@ export const FileUploadModal = ({
/> />
{/* Fields: Entity Type + Entity ID */} {/* Fields: Entity Type + Entity ID */}
<div className="grid grid-cols-2 gap-4 pb-0"> <div className="grid grid-cols-2 gap-4">
<FormSelect <div>
label="Entity Type" <label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">
required Entity Type <span className="text-red-500">*</span>
</label>
<select
value={entityType} value={entityType}
onValueChange={setEntityType} onChange={(e) => setEntityType(e.target.value)}
disabled={!!defaultEntityType || isUploading} disabled={!!defaultEntityType || isUploading}
options={ENTITY_TYPES.map((t) => ({ 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]"
value: t, >
label: t.charAt(0).toUpperCase() + t.slice(1), <option value="">Select type</option>
}))} {ENTITY_TYPES.map((t) => (
placeholder="Select type" <option key={t} value={t}>
/> {t.charAt(0).toUpperCase() + t.slice(1)}
<div className="relative"> </option>
<FormField ))}
label="Entity ID" </select>
required </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} value={entityId}
onChange={(e) => setEntityId(e.target.value)} onChange={(e) => setEntityId(e.target.value)}
disabled={!!defaultEntityId || isUploading} disabled={!!defaultEntityId || isUploading}
placeholder="e.g. PRJ-1204" 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 && ( {!defaultEntityId && !isUploading && isTenantAdmin && (
<button <button
onClick={() => setEntityId(generateUUID())} onClick={() => setEntityId(generateUUID())}
title="Regenerate ID" title="Regenerate ID"
className="absolute right-3 top-[38px] p-1 text-[#9aa6b2] hover:text-[#084cc8] transition-colors" className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-[#9aa6b2] hover:text-[#084cc8] transition-colors"
type="button"
> >
<RefreshCw className="w-3.5 h-3.5" /> <RefreshCw className="w-3.5 h-3.5" />
</button> </button>
)} )}
</div> </div>
</div> </div>
</div>
{/* Category Name (Editable Combobox) */} {/* Category Name (Editable Combobox) */}
<div className="relative pb-4" ref={categoryDropdownRef}> <div className="relative" ref={categoryDropdownRef}>
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5"> <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> Category Name <span className="text-[#9aa6b2] font-normal">(Select or Enter New)</span>
</label> </label>
@ -551,12 +537,11 @@ export const FileUploadModal = ({
onFocus={() => setShowCategoryDropdown(true)} onFocus={() => setShowCategoryDropdown(true)}
disabled={isUploading} disabled={isUploading}
placeholder="Type or select a category..." 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]" 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]"> <div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-[#9aa6b2]">
{categoryInput && ( {categoryInput && (
<button <button
type="button"
onClick={() => { setCategoryInput(""); setCategorySearch(""); }} onClick={() => { setCategoryInput(""); setCategorySearch(""); }}
className="p-1 hover:text-red-500" className="p-1 hover:text-red-500"
> >
@ -575,7 +560,6 @@ export const FileUploadModal = ({
.map((cat) => ( .map((cat) => (
<button <button
key={cat.category_id ?? cat.category} key={cat.category_id ?? cat.category}
type="button"
onClick={() => { onClick={() => {
setCategoryInput(cat.category); setCategoryInput(cat.category);
setShowCategoryDropdown(false); setShowCategoryDropdown(false);
@ -596,9 +580,9 @@ export const FileUploadModal = ({
</div> </div>
{/* Tags */} {/* Tags */}
<div className="pb-4"> <div>
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">Tags</label> <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]"> <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) => ( {tags.map((tag) => (
<span <span
key={tag} key={tag}
@ -606,7 +590,6 @@ export const FileUploadModal = ({
> >
{tag} {tag}
<button <button
type="button"
onClick={() => removeTag(tag)} onClick={() => removeTag(tag)}
disabled={isUploading} disabled={isUploading}
className="text-[#9aa6b2] hover:text-red-500" className="text-[#9aa6b2] hover:text-red-500"
@ -633,15 +616,18 @@ export const FileUploadModal = ({
</div> </div>
{/* Description */} {/* Description */}
<FormTextArea <div>
label="Description" <label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">Description</label>
<textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
disabled={isUploading} disabled={isUploading}
maxLength={500} maxLength={500}
rows={3} rows={3}
placeholder="Description of this file..." 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 */} {/* Upload error */}
{uploadError && ( {uploadError && (
@ -651,8 +637,39 @@ export const FileUploadModal = ({
</div> </div>
)} )}
</div> </div>
</Modal>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[rgba(0,0,0,0.08)] shrink-0">
<button
onClick={handleClose}
disabled={isUploading}
className="h-9 px-4 border border-[rgba(0,0,0,0.12)] rounded-lg text-sm font-medium text-[#0e1b2a] hover:bg-gray-50 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleUpload}
disabled={isUploading || files.length === 0 || validCount === 0}
className="h-9 px-4 bg-[#112868] hover:bg-[#0c1e52] text-white rounded-lg text-sm font-semibold flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Uploading ({doneCount}/{validCount})
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload {validCount > 0 ? `(${validCount})` : ""}
</>
)}
</button>
</div>
</div>
</div>
); );
return createPortal(content, document.body);
}; };
export default FileUploadModal; export default FileUploadModal;

View File

@ -1,251 +0,0 @@
/**
* 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,7 +3,6 @@ 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[];
@ -31,7 +30,6 @@ 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);
@ -135,17 +133,13 @@ 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 && "text-[#475569]" value ? "border-[#112868]/20 bg-[#112868]/5" : "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="font-medium" style={value ? { color: primaryColor } : {}}>{label}</span> <span className={cn("font-medium", value && "text-[#112868]")}>{label}</span>
{value && ( {value && (
<span <span className="text-[#112868] font-bold text-xs bg-white px-1.5 py-0.5 rounded border border-[#112868]/10 ml-0.5">
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>
)} )}
@ -170,19 +164,7 @@ 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 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 focus:ring-[#112868]/20 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,7 +3,6 @@ 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;
@ -37,7 +36,6 @@ 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);
@ -191,18 +189,7 @@ 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 text-white rounded text-xs 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 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]"
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 { useAppTheme } from '@/hooks/useAppTheme'; import { useAppSelector } from '@/hooks/redux-hooks';
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,7 +39,31 @@ export const PrimaryButton = ({
...props ...props
}: PrimaryButtonProps): ReactElement => { }: PrimaryButtonProps): ReactElement => {
const buttonVariant = disabled ? 'disabled' : variant || 'default'; const buttonVariant = disabled ? 'disabled' : variant || 'default';
const { primaryColor, secondaryColor } = useAppTheme(); const { theme } = useAppSelector((state) => state.theme);
const { roles } = useAppSelector((state) => state.auth);
// Check if user is tenant admin (not super_admin) or if we're on a tenant route
let rolesArray: string[] = [];
if (Array.isArray(roles)) {
rolesArray = roles;
} else if (typeof roles === 'string') {
try {
rolesArray = JSON.parse(roles);
} catch {
rolesArray = [];
}
}
const isSuperAdmin = rolesArray.includes('super_admin');
const isTenantAdmin = !isSuperAdmin && rolesArray.length > 0;
// Check if we're on a tenant route (for login page where user might not be authenticated)
// Use /tenant/ or exactly /tenant to avoid matching /tenants page
const isTenantRoute = typeof window !== 'undefined' && (window.location.pathname.startsWith('/tenant/') || window.location.pathname === '/tenant');
// Use theme colors for tenant admin or tenant routes, default colors for super admin
const shouldUseTheme = (isTenantAdmin || (isTenantRoute && !isSuperAdmin)) && theme;
const primaryColor = shouldUseTheme && theme.primary_color ? theme.primary_color : '#112868';
const secondaryColor = shouldUseTheme && theme.secondary_color ? theme.secondary_color : '#23dce1';
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 { useAppTheme } from '@/hooks/useAppTheme'; import { useAppSelector } from '@/hooks/redux-hooks';
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,7 +32,31 @@ export const SecondaryButton = ({
...props ...props
}: SecondaryButtonProps): ReactElement => { }: SecondaryButtonProps): ReactElement => {
const buttonVariant = disabled ? 'disabled' : variant || 'default'; const buttonVariant = disabled ? 'disabled' : variant || 'default';
const { primaryColor, secondaryColor } = useAppTheme(); const { theme } = useAppSelector((state) => state.theme);
const { roles } = useAppSelector((state) => state.auth);
// Check if user is tenant admin (not super_admin) or if we're on a tenant route
let rolesArray: string[] = [];
if (Array.isArray(roles)) {
rolesArray = roles;
} else if (typeof roles === 'string') {
try {
rolesArray = JSON.parse(roles);
} catch {
rolesArray = [];
}
}
const isSuperAdmin = rolesArray.includes('super_admin');
const isTenantAdmin = !isSuperAdmin && rolesArray.length > 0;
// Check if we're on a tenant route (for login page where user might not be authenticated)
// Use /tenant/ or exactly /tenant to avoid matching /tenants page
const isTenantRoute = typeof window !== 'undefined' && (window.location.pathname.startsWith('/tenant/') || window.location.pathname === '/tenant');
// Use theme colors for tenant admin or tenant routes, default colors for super admin
const shouldUseTheme = (isTenantAdmin || (isTenantRoute && !isSuperAdmin)) && theme;
const primaryColor = shouldUseTheme && theme.primary_color ? theme.primary_color : '#112868';
const secondaryColor = shouldUseTheme && theme.secondary_color ? theme.secondary_color : '#23dce1';
return ( return (
<button <button

View File

@ -1,7 +1,6 @@
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',
@ -37,18 +36,9 @@ export const StatusBadge = ({
variant = 'success', variant = 'success',
className, className,
}: StatusBadgeProps): ReactElement => { }: StatusBadgeProps): ReactElement => {
const { primaryColor } = useAppTheme();
const isInfo = variant === 'info';
return ( return (
<div <div className={cn(statusBadgeVariants({ variant }), className)}>
className={cn(statusBadgeVariants({ variant }), className)} <div className={cn('rounded size-1.5 shrink-0', variant && statusDotColors[variant])} />
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,7 +16,6 @@ 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;
@ -60,7 +59,6 @@ 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);
@ -290,20 +288,7 @@ 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-2 w-full sm:w-64 transition-all" 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"
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,7 +24,6 @@ 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)
@ -37,7 +36,6 @@ 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;
@ -259,20 +257,7 @@ 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-2 transition-all" 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]"
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,7 +24,6 @@ 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)
@ -37,7 +36,6 @@ 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;
@ -250,20 +248,7 @@ 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-2 transition-all" 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]"
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,10 +2,8 @@ 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);
@ -52,7 +50,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" color={primaryColor} strokeWidth={2} /> <Icon className="w-4 h-4 text-[#084cc8]" 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,7 +4,6 @@ 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';
@ -47,7 +46,6 @@ 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);
@ -82,8 +80,7 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-[11px] font-bold gap-1 h-7" className="text-[11px] font-bold text-[#084cc8] hover:bg-[#084cc8]/5 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" />
@ -93,7 +90,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 animate-spin" style={{ color: primaryColor }} /> <Loader2 className="w-6 h-6 text-[#084cc8] animate-spin" />
</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>
@ -107,13 +104,8 @@ 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">{log.action}</span> <span className="text-[12px] font-bold text-[#111827]">{log.action}</span>
<span <span className="text-[12px] font-bold text-[#084cc8] hover:underline cursor-pointer truncate">{log.resource_type}</span>
className="text-[12px] font-bold hover:underline cursor-pointer truncate"
style={{ color: primaryColor }}
>
{log.resource_type}
</span>
</div> </div>
</div> </div>
</div> </div>
@ -163,12 +155,7 @@ 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 <span className="text-[12px] font-bold text-[#084cc8] truncate max-w-[150px] inline-block">{log.resource_type}</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)}>

View File

@ -1,54 +0,0 @@
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,8 +13,6 @@ 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
@ -60,7 +58,6 @@ 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');
@ -312,8 +309,7 @@ const AuditLogs = (): ReactElement => {
<button <button
type="button" type="button"
onClick={() => handleViewAuditLog(log.id)} onClick={() => handleViewAuditLog(log.id)}
className="text-sm font-medium transition-colors hover:opacity-80" className="text-sm text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors"
style={{ color: primaryColor }}
> >
View View
</button> </button>
@ -337,8 +333,7 @@ const AuditLogs = (): ReactElement => {
<button <button
type="button" type="button"
onClick={() => handleViewAuditLog(log.id)} onClick={() => handleViewAuditLog(log.id)}
className="text-sm font-medium transition-colors shrink-0 hover:opacity-80" className="text-sm text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors shrink-0"
style={{ color: primaryColor }}
> >
View View
</button> </button>
@ -401,15 +396,7 @@ 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 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 focus:ring-[#112868]/10 focus:border-[#112868] 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>
@ -485,14 +472,14 @@ const AuditLogs = (): ReactElement => {
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isTenantAdmin && ( {isTenantAdmin && (
<PrimaryButton <button
type="button"
onClick={handleExport} onClick={handleExport}
size="small" 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"
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>
</PrimaryButton> </button>
)} )}
</div> </div>
</div> </div>
@ -535,8 +522,7 @@ const AuditLogs = (): ReactElement => {
setSearch(''); setSearch('');
setCurrentPage(1); setCurrentPage(1);
}} }}
className="text-xs hover:underline decoration-offset-2" className="text-xs text-[#ef4444] hover:underline"
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,7 +11,6 @@ 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";
@ -33,15 +32,11 @@ 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 <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" />
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">
@ -74,7 +69,6 @@ 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();
@ -124,10 +118,7 @@ const TaskCard = ({ task }: { task: WorkflowTask }) => {
> >
View View
</button> </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">
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>
@ -199,7 +190,6 @@ 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);
@ -346,8 +336,7 @@ 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 hover:underline" className="text-[11px] font-bold text-[#084cc8] hover:underline"
style={{ color: primaryColor }}
> >
View all View all
</button> </button>

View File

@ -12,7 +12,6 @@ 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 => {
@ -31,7 +30,6 @@ 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[]>([]);
@ -111,8 +109,7 @@ const Documents = (): ReactElement => {
render: (doc) => ( render: (doc) => (
<button <button
type="button" type="button"
className="hover:underline transition-colors" className="text-[#084cc8] hover:underline"
style={{ color: primaryColor }}
onClick={() => navigate(`/tenant/documents/${doc.id}`)} onClick={() => navigate(`/tenant/documents/${doc.id}`)}
> >
{doc.document_number} {doc.document_number}
@ -140,10 +137,7 @@ const Documents = (): ReactElement => {
key: "status", key: "status",
label: "Status", label: "Status",
render: (doc) => ( render: (doc) => (
<span <span className="inline-flex items-center rounded-md bg-[#112868]/10 px-2 py-1 text-[11px] text-[#112868]">
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>
), ),
@ -175,8 +169,7 @@ const Documents = (): ReactElement => {
render: (doc) => ( render: (doc) => (
<button <button
type="button" type="button"
className="text-xs transition-colors hover:underline font-medium" className="text-xs text-[#084cc8] 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}`);
@ -232,20 +225,7 @@ 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-2 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-1 focus:ring-[#112868]/10 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,11 +26,9 @@ 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/FileShareModal"; import { FileShareModal } from "@/components/shared";
import { FileVersionUploadModal } from "@/components/shared/FileVersionUploadModal";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import fileAttachmentService, { import fileAttachmentService, {
type FileAttachment, type FileAttachment,
@ -80,53 +78,19 @@ 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 | undefined>(undefined); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
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(() => {
const loadPreview = async () => {
setLoading(true); setLoading(true);
setErr(false); setErr(false);
setExtractedHtml(null); fileAttachmentService
setPreviewUrl(undefined); .getPreviewUrl(file.id)
.then((url) => setPreviewUrl(url))
const isOffice = [ .catch(() => setErr(true))
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", .finally(() => setLoading(false));
"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);
@ -135,6 +99,7 @@ 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 (
@ -144,18 +109,12 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
); );
} }
if (err || (!previewUrl && !extractedHtml)) { if (err || !previewUrl) {
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 text-center px-4">{file.mime_type}</p> <p className="text-xs">{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>
); );
} }
@ -195,30 +154,17 @@ 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>
) : previewUrl ? ( ) : isPdf || file.mime_type?.startsWith("text/") ? (
<iframe <iframe
src={previewUrl} src={previewUrl}
title={file.original_name} title={file.original_name}
className="flex-1 w-full border-0 bg-white" className="flex-1 w-full border-0"
/> />
) : 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">Preview not available</p> <p className="text-sm">No visual preview 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>
@ -287,8 +233,7 @@ 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 [isShareModalOpen, setIsShareModalOpen] = useState(false); const [showShareModal, setShowShareModal] = useState(false);
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
// Metadata edit form // Metadata edit form
const [draftDescription, setDraftDescription] = useState(""); const [draftDescription, setDraftDescription] = useState("");
@ -407,14 +352,7 @@ const FileView = (): ReactElement => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => setIsVersionModalOpen(true)} onClick={() => setShowShareModal(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" />
@ -665,24 +603,11 @@ const FileView = (): ReactElement => {
</div> </div>
</div> </div>
{isShareModalOpen && file && (
<FileShareModal <FileShareModal
isOpen={isShareModalOpen} isOpen={showShareModal}
onClose={() => setIsShareModalOpen(false)} onClose={() => setShowShareModal(false)}
file={file} file={file}
/> />
)}
{isVersionModalOpen && file && (
<FileVersionUploadModal
isOpen={isVersionModalOpen}
onClose={() => setIsVersionModalOpen(false)}
file={file}
onUploaded={(newFile) => {
navigate(`/tenant/files/${newFile.id}`);
}}
/>
)}
</Layout> </Layout>
); );
}; };

View File

@ -19,21 +19,23 @@ 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, ActionDropdown } from "@/components/shared"; import { FilterDropdown } 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";
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@ -58,7 +60,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, primaryColor: string): ReactElement { function getFileIcon(mime: string, name: 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 (
@ -70,7 +72,7 @@ function getFileIcon(mime: string, name: string, primaryColor: string): ReactEle
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" style={{ color: primaryColor }} />; return <FileText className="w-4 h-4 text-[#084cc8]" />;
} }
const categoryColors: Record<string, string> = { const categoryColors: Record<string, string> = {
@ -140,7 +142,6 @@ 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">
@ -149,13 +150,12 @@ 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
? "bg-opacity-5" ? "border-[#084cc8] bg-[#084cc8]/5 text-[#084cc8]"
: "border-[rgba(0,0,0,0.1)] bg-white text-[#475569]" : "border-[rgba(0,0,0,0.1)] bg-white text-[#475569] hover:border-[#084cc8]/30"
)} )}
style={value ? { borderColor: primaryColor, backgroundColor: `${primaryColor}10`, color: primaryColor } : {}}
> >
{label} {label}
{selected && <span style={{ color: primaryColor }}>: {selected.label}</span>} {selected && <span className="text-[#084cc8]">: {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,12 +193,78 @@ 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
@ -226,7 +292,7 @@ const FilesList = (): ReactElement => {
// Pagination // Pagination
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [limit, setLimit] = useState(10); const [limit] = 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));
@ -237,9 +303,7 @@ const FilesList = (): ReactElement => {
const [showUpload, setShowUpload] = useState(false); const [showUpload, setShowUpload] = useState(false);
// Deleting // Deleting
const [fileToDelete, setFileToDelete] = useState<{ id: string; name: string } | null>(null); const [, setDeletingId] = useState<string | null>(null);
const [isHardDelete, setIsHardDelete] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// ── Load categories ── // ── Load categories ──
useEffect(() => { useEffect(() => {
@ -288,26 +352,19 @@ const FilesList = (): ReactElement => {
fileAttachmentService.download(file.id, file.original_name).catch(() => {}); fileAttachmentService.download(file.id, file.original_name).catch(() => {});
}; };
const handleDelete = async () => { const handleDelete = async (id: string) => {
if (!fileToDelete) return; if (!window.confirm("Delete this file?")) return;
setIsDeleting(true); setDeletingId(id);
try { try {
await fileAttachmentService.delete(fileToDelete.id, isHardDelete); await fileAttachmentService.delete(id);
setFileToDelete(null);
setIsHardDelete(false);
await loadFiles(); await loadFiles();
} catch (err: any) { } catch {
alert(err?.response?.data?.error?.message || "Failed to delete file"); // silence
} finally { } finally {
setIsDeleting(false); setDeletingId(null);
} }
}; };
const openDeleteConfirm = (file: FileAttachment) => {
setFileToDelete({ id: file.id, name: file.original_name });
setIsHardDelete(false);
};
const clearFilters = () => { const clearFilters = () => {
setSearch(""); setSearch("");
setCategoryFilter(null); setCategoryFilter(null);
@ -321,22 +378,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 ? (
<PrimaryButton <button
id="upload-new-file-btn" id="upload-new-file-btn"
onClick={() => setShowUpload(true)} onClick={() => setShowUpload(true)}
className="flex items-center gap-2" 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"
> >
<Upload className="w-3.5 h-3.5" /> <Upload className="w-3.5 h-3.5" />
Upload New File Upload New File
</PrimaryButton> </button>
) : null, ) : null,
}} }}
> >
@ -353,20 +410,7 @@ 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" 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]"
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>
@ -449,8 +493,7 @@ const FilesList = (): ReactElement => {
{canCreate && ( {canCreate && (
<button <button
onClick={() => setShowUpload(true)} onClick={() => setShowUpload(true)}
className="mt-2 text-sm font-medium hover:underline" className="mt-2 text-sm font-medium text-[#084cc8] hover:underline"
style={{ color: primaryColor }}
> >
Upload your first file Upload your first file
</button> </button>
@ -468,16 +511,12 @@ 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 transition-colors text-left group/link" className="flex items-center gap-2.5 hover:text-[#084cc8] transition-colors text-left"
> >
<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, primaryColor)} {getFileIcon(file.mime_type, file.original_name)}
</div> </div>
<span <span className="text-sm font-medium text-[#0e1b2a] group-hover:text-[#084cc8] truncate max-w-[200px]">
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>
@ -555,11 +594,13 @@ const FilesList = (): ReactElement => {
{/* Actions */} {/* Actions */}
<td className="px-4 py-3"> <td className="px-4 py-3">
<ActionDropdown <ActionMenu
onView={() => navigate(`/tenant/files/${file.id}`)} onView={() => navigate(`/tenant/files/${file.id}`)}
onDownload={() => handleDownload(file)} onDownload={() => handleDownload(file)}
onEdit={canUpdate ? () => navigate(`/tenant/files/${file.id}`) : undefined} onEdit={() => navigate(`/tenant/files/${file.id}`)}
onDelete={canDelete ? () => openDeleteConfirm(file) : undefined} onDelete={() => handleDelete(file.id)}
canEdit={canUpdate}
canDelete={canDelete}
/> />
</td> </td>
</tr> </tr>
@ -577,7 +618,7 @@ const FilesList = (): ReactElement => {
totalItems={total} totalItems={total}
limit={limit} limit={limit}
onPageChange={setCurrentPage} onPageChange={setCurrentPage}
onLimitChange={setLimit} onLimitChange={() => {}}
/> />
)} )}
</div> </div>
@ -593,34 +634,6 @@ 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,7 +9,6 @@ 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' => {
@ -27,7 +26,6 @@ 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);
@ -133,8 +131,7 @@ const Modules = (): ReactElement => {
<button <button
type="button" type="button"
onClick={() => handleLaunchModule(module.id)} onClick={() => handleLaunchModule(module.id)}
className="text-sm font-medium transition-colors cursor-pointer hover:opacity-80" className="text-sm text-[#FFC107] hover:text-[#FFC107] font-medium transition-colors cursor-pointer"
style={{ color: primaryColor }}
> >
Launch Launch
</button> </button>
@ -175,8 +172,7 @@ const Modules = (): ReactElement => {
<button <button
type="button" type="button"
onClick={() => handleLaunchModule(module.id)} onClick={() => handleLaunchModule(module.id)}
className="text-sm font-medium transition-colors hover:opacity-80" className="text-sm text-[#FFC107] hover:text-[#FFC107] font-medium transition-colors"
style={{ color: primaryColor }}
> >
Launch Launch
</button> </button>

View File

@ -1,378 +0,0 @@
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,7 +11,6 @@ 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 => {
@ -25,12 +24,9 @@ const formatDate = (value?: string | null): string => {
}); });
}; };
const StatCard = ({ icon: Icon, label, value, color, style }: { icon: any, label: string, value: number, color?: string, style?: React.CSSProperties }) => ( const StatCard = ({ icon: Icon, label, value, color }: { icon: any, label: string, value: number, color: string }) => (
<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 <div className={cn("w-12 h-12 rounded-lg flex items-center justify-center", color)}>
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>
@ -41,7 +37,6 @@ const StatCard = ({ icon: Icon, label, value, color, style }: { icon: any, label
); );
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);
@ -129,10 +124,7 @@ 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 <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">
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>
@ -184,8 +176,7 @@ const Tasks = (): ReactElement => {
navigate(`/tenant/documents/${task.entity.id}`); navigate(`/tenant/documents/${task.entity.id}`);
} }
}} }}
className="font-bold text-sm transition-colors hover:opacity-80" className="text-[#084cc8] hover:text-[#063ba1] font-bold text-sm"
style={{ color: primaryColor }}
> >
View View
</button> </button>
@ -210,7 +201,7 @@ const Tasks = (): ReactElement => {
icon={Inbox} icon={Inbox}
label="Pending Tasks" label="Pending Tasks"
value={counts?.pending || 0} value={counts?.pending || 0}
style={{ backgroundColor: primaryColor }} color="bg-blue-600"
/> />
<StatCard <StatCard
icon={Clock} icon={Clock}

View File

@ -19,7 +19,6 @@ 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 => {
@ -57,7 +56,6 @@ const getStatusVariant = (
}; };
const Users = (): ReactElement => { const Users = (): ReactElement => {
const { primaryColor } = useAppTheme();
const { canCreate, canUpdate const { canCreate, canUpdate
// , canDelete // , canDelete
} = usePermissions(); } = usePermissions();
@ -270,8 +268,7 @@ 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" className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-[#112868]/10 text-[#112868]"
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})`}
@ -281,8 +278,7 @@ 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" className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-[#112868]/10 text-[#112868]"
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
> >
{role.name} {role.name}
</span> </span>

View File

@ -19,7 +19,6 @@ 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 => {
@ -53,7 +52,6 @@ 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);
@ -506,8 +504,7 @@ const ViewDocument = (): ReactElement => {
</button> </button>
<button <button
type="button" type="button"
className="inline-flex items-center gap-2 h-9 px-4 rounded-md text-white text-xs font-medium transition-colors hover:opacity-90" 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"
style={{ backgroundColor: primaryColor }}
onClick={() => void openActionModal("submit")} onClick={() => void openActionModal("submit")}
> >
{ACTION_LABELS["submit"]} {ACTION_LABELS["submit"]}
@ -518,8 +515,7 @@ const ViewDocument = (): ReactElement => {
<> <>
<button <button
type="button" type="button"
className="inline-flex items-center gap-2 h-9 px-4 rounded-md text-white text-xs font-medium opacity-90 hover:opacity-100" className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-[#112868] text-white text-xs font-medium hover:bg-[#0c1d4a]"
style={{ backgroundColor: primaryColor }}
onClick={() => void openWorkflowTracker()} onClick={() => void openWorkflowTracker()}
> >
Workflow Status Workflow Status
@ -544,8 +540,7 @@ 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 text-white text-xs font-medium hover:opacity-90" className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-[#084cc8] text-white text-xs font-medium hover:bg-[#063a99]"
style={{ backgroundColor: primaryColor }}
onClick={() => void openActionModal("effective")} onClick={() => void openActionModal("effective")}
> >
{ACTION_LABELS["effective"]} {ACTION_LABELS["effective"]}
@ -592,10 +587,7 @@ 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 <span className="px-2 py-1 rounded-full bg-blue-100 text-blue-700 text-[11px] font-medium">
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 && (
@ -610,33 +602,21 @@ 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 transition-colors`} className={`text-sm pb-2 ${activeTab === "overview" ? "text-[#084cc8] border-b-2 border-[#084cc8]" : "text-[#6b7280]"}`}
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 transition-colors`} className={`text-sm pb-2 ${activeTab === "version-history" ? "text-[#084cc8] border-b-2 border-[#084cc8]" : "text-[#6b7280]"}`}
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 transition-colors`} className={`text-sm pb-2 ${activeTab === "workflow-history" ? "text-[#084cc8] border-b-2 border-[#084cc8]" : "text-[#6b7280]"}`}
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
@ -721,8 +701,7 @@ 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 text-white text-xs hover:opacity-90 transition-colors" className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md bg-[#112868] text-white text-xs"
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,20 +19,13 @@ 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( const DocumentCategories = lazy(() => import("@/pages/tenant/DocumentCategories"));
() => import("@/pages/tenant/DocumentCategories"), const DocumentsDueForReview = lazy(() => import("@/pages/tenant/DocumentsDueForReview"));
);
const DocumentsDueForReview = lazy(
() => import("@/pages/tenant/DocumentsDueForReview"),
);
const Tasks = lazy(() => import("@/pages/tenant/Tasks")); const Tasks = lazy(() => import("@/pages/tenant/Tasks"));
const NotificationSettings = lazy( const NotificationSettings = lazy(() => import("@/pages/tenant/NotificationSettings"));
() => 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 => (
@ -147,8 +140,4 @@ 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; color?: string; style?: React.CSSProperties }>; icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
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; color?: string; style?: React.CSSProperties }>; icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
label: string; label: string;
onClick: () => void; onClick: () => void;
} }