Compare commits
No commits in common. "f51e0af9c8c1180bd29538832806bfdb33e793c4" and "9647e3e632e3a1c41e9a175a77b8a3ac474b72b8" have entirely different histories.
f51e0af9c8
...
9647e3e632
@ -20,7 +20,7 @@ import {
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAppSelector } from "@/hooks/redux-hooks";
|
||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||
import { useTenantTheme } from "@/hooks/useTenantTheme";
|
||||
import { AuthenticatedImage } from "@/components/shared";
|
||||
|
||||
interface MenuItem {
|
||||
@ -109,11 +109,6 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [
|
||||
path: "/tenant/files",
|
||||
requiredPermission: { resource: "files" },
|
||||
},
|
||||
{
|
||||
label: "Storage Dashboard",
|
||||
path: "/tenant/files/storage-dashboard",
|
||||
requiredPermission: { resource: "files" },
|
||||
},
|
||||
],
|
||||
requiredPermission: { resource: "files" },
|
||||
},
|
||||
@ -190,15 +185,15 @@ const GroupMenuItem = ({
|
||||
item,
|
||||
childrenItems,
|
||||
location,
|
||||
primaryColor,
|
||||
secondaryColor,
|
||||
isSuperAdmin,
|
||||
theme,
|
||||
onClose,
|
||||
}: {
|
||||
item: MenuItem;
|
||||
childrenItems: any[];
|
||||
location: any;
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
isSuperAdmin: boolean;
|
||||
theme: any;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const isChildActive = (path: string) => {
|
||||
@ -210,14 +205,6 @@ const GroupMenuItem = ({
|
||||
);
|
||||
if (isSubActionActive) return false;
|
||||
}
|
||||
|
||||
// Special handling for Files List to NOT show as active when Storage Dashboard is active
|
||||
if (path === "/tenant/files") {
|
||||
if (location.pathname.startsWith("/tenant/files/storage-dashboard")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
location.pathname === path || location.pathname.startsWith(`${path}/`)
|
||||
);
|
||||
@ -246,8 +233,14 @@ const GroupMenuItem = ({
|
||||
style={
|
||||
isAnyChildActive
|
||||
? {
|
||||
backgroundColor: primaryColor,
|
||||
color: secondaryColor,
|
||||
backgroundColor:
|
||||
!isSuperAdmin && theme?.primary_color
|
||||
? theme.primary_color
|
||||
: "#112868",
|
||||
color:
|
||||
!isSuperAdmin && theme?.secondary_color
|
||||
? theme.secondary_color
|
||||
: "#23dce1",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@ -290,7 +283,10 @@ const GroupMenuItem = ({
|
||||
style={
|
||||
isActive
|
||||
? {
|
||||
color: primaryColor,
|
||||
color:
|
||||
!isSuperAdmin && theme?.primary_color
|
||||
? theme.primary_color
|
||||
: "#112868",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@ -308,9 +304,9 @@ const GroupMenuItem = ({
|
||||
};
|
||||
|
||||
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
const { primaryColor, secondaryColor, accentColor, logoUrl } = useAppTheme();
|
||||
const location = useLocation();
|
||||
const { roles, permissions } = useAppSelector((state) => state.auth);
|
||||
const { theme, logoUrl } = useAppSelector((state) => state.theme);
|
||||
|
||||
// Fetch theme for tenant admin
|
||||
const isSuperAdminCheck = () => {
|
||||
@ -361,9 +357,9 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
const roleName = getRoleName();
|
||||
|
||||
// Fetch theme if tenant admin
|
||||
// if (!isSuperAdmin) {
|
||||
// useTenantTheme();
|
||||
// }
|
||||
if (!isSuperAdmin) {
|
||||
useTenantTheme();
|
||||
}
|
||||
|
||||
// Helper function to check if user has permission for a resource
|
||||
const hasPermission = (
|
||||
@ -465,8 +461,8 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
item={item}
|
||||
childrenItems={children}
|
||||
location={location}
|
||||
primaryColor={primaryColor}
|
||||
secondaryColor={secondaryColor}
|
||||
isSuperAdmin={isSuperAdmin}
|
||||
theme={theme}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
@ -498,8 +494,14 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
style={
|
||||
isActive
|
||||
? {
|
||||
backgroundColor: primaryColor,
|
||||
color: secondaryColor,
|
||||
backgroundColor:
|
||||
!isSuperAdmin && theme?.primary_color
|
||||
? theme.primary_color
|
||||
: "#112868",
|
||||
color:
|
||||
!isSuperAdmin && theme?.secondary_color
|
||||
? theme.secondary_color
|
||||
: "#23dce1",
|
||||
}
|
||||
: 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"
|
||||
style={{
|
||||
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} />
|
||||
@ -556,7 +561,10 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
<div
|
||||
className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2"
|
||||
style={{
|
||||
color: accentColor
|
||||
color:
|
||||
!isSuperAdmin && theme?.accent_color
|
||||
? theme.accent_color
|
||||
: "#084cc8",
|
||||
}}
|
||||
>
|
||||
{roleName}
|
||||
@ -617,10 +625,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
alt="Logo"
|
||||
className="h-9 w-auto max-w-[180px] object-contain"
|
||||
fallback={
|
||||
<div
|
||||
className="w-9 h-9 rounded-[10px] flex items-center justify-center shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)] shrink-0"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
<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]">
|
||||
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
|
||||
</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"
|
||||
style={{
|
||||
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} />
|
||||
@ -643,7 +651,10 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
<div
|
||||
className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2"
|
||||
style={{
|
||||
color: accentColor
|
||||
color:
|
||||
!isSuperAdmin && theme?.accent_color
|
||||
? theme.accent_color
|
||||
: "#084cc8",
|
||||
}}
|
||||
>
|
||||
{roleName}
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
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 { useAppTheme } from '@/hooks/useAppTheme';
|
||||
|
||||
export interface ActionItem {
|
||||
label: string;
|
||||
@ -18,7 +17,6 @@ interface ActionDropdownProps {
|
||||
onDelete?: () => void;
|
||||
onContacts?: () => void;
|
||||
onScorecards?: () => void;
|
||||
onDownload?: () => void;
|
||||
actions?: ActionItem[];
|
||||
trigger?: React.ReactNode;
|
||||
className?: string;
|
||||
@ -30,12 +28,10 @@ export const ActionDropdown = ({
|
||||
onDelete,
|
||||
onContacts,
|
||||
onScorecards,
|
||||
onDownload,
|
||||
actions,
|
||||
trigger,
|
||||
className,
|
||||
}: ActionDropdownProps): ReactElement => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [dropdownStyle, setDropdownStyle] = useState<{ top?: string; bottom?: string; right: string; width: string }>({ right: '0', width: '0' });
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
@ -71,22 +67,14 @@ export const ActionDropdown = ({
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
const dropdownHeight = (
|
||||
(onView ? 1 : 0) +
|
||||
(onEdit ? 1 : 0) +
|
||||
(onDelete ? 1 : 0) +
|
||||
(onContacts ? 1 : 0) +
|
||||
(onScorecards ? 1 : 0) +
|
||||
(onDownload ? 1 : 0) +
|
||||
(actions ? actions.length : 0)
|
||||
) * 36 + 12;
|
||||
const dropdownHeight = actions ? actions.length * 32 + 16 : 120; // Approximate height based on actions
|
||||
|
||||
// Determine if should open upward or downward
|
||||
const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
|
||||
|
||||
// Calculate dropdown position
|
||||
const right = window.innerWidth - rect.right;
|
||||
const width = (actions || onScorecards || onDownload || onContacts) ? 140 : 100; // Wider for longer labels
|
||||
const width = actions ? 140 : 76; // Wider for custom actions
|
||||
|
||||
if (shouldOpenUp) {
|
||||
// Position above the button
|
||||
@ -137,25 +125,12 @@ export const ActionDropdown = ({
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
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"
|
||||
style={isOpen
|
||||
? { backgroundColor: primaryColor, color: 'white', borderColor: primaryColor }
|
||||
: { backgroundColor: 'white', color: '#0f1724' }
|
||||
}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isOpen) {
|
||||
e.currentTarget.style.backgroundColor = primaryColor;
|
||||
e.currentTarget.style.color = 'white';
|
||||
e.currentTarget.style.borderColor = primaryColor;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isOpen) {
|
||||
e.currentTarget.style.backgroundColor = 'white';
|
||||
e.currentTarget.style.color = '#0f1724';
|
||||
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)';
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-7 h-7 rounded-full border border-[rgba(0,0,0,0.08)] transition-colors cursor-pointer',
|
||||
isOpen
|
||||
? 'bg-[#084cc8] text-white'
|
||||
: 'bg-white text-[#0f1724] hover:bg-[#084cc8] hover:text-white'
|
||||
)}
|
||||
aria-label="Actions"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
@ -194,9 +169,9 @@ export const ActionDropdown = ({
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</button>
|
||||
)}
|
||||
@ -204,9 +179,9 @@ export const ActionDropdown = ({
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</button>
|
||||
)}
|
||||
@ -214,9 +189,9 @@ export const ActionDropdown = ({
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</button>
|
||||
)}
|
||||
@ -224,9 +199,9 @@ export const ActionDropdown = ({
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</button>
|
||||
)}
|
||||
@ -234,22 +209,12 @@ export const ActionDropdown = ({
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</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>
|
||||
|
||||
@ -12,7 +12,6 @@ interface DeleteConfirmationModalProps {
|
||||
message: string;
|
||||
itemName?: string;
|
||||
isLoading?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DeleteConfirmationModal = ({
|
||||
@ -23,7 +22,6 @@ export const DeleteConfirmationModal = ({
|
||||
message,
|
||||
itemName,
|
||||
isLoading = false,
|
||||
children,
|
||||
}: DeleteConfirmationModalProps): ReactElement | null => {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -67,10 +65,10 @@ export const DeleteConfirmationModal = ({
|
||||
if (!isOpen) return null;
|
||||
|
||||
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
|
||||
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 */}
|
||||
<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.
|
||||
</p>
|
||||
{children && <div className="mt-4">{children}</div>}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
|
||||
@ -13,7 +13,6 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Modal } from "./Modal";
|
||||
import fileAttachmentService, { type FileAttachment } from "@/services/file-attachment-service";
|
||||
import { DeleteConfirmationModal } from "./DeleteConfirmationModal";
|
||||
|
||||
interface FileShareModalProps {
|
||||
isOpen: boolean;
|
||||
@ -31,9 +30,7 @@ export const FileShareModal: React.FC<FileShareModalProps> = ({
|
||||
const [permissions, setPermissions] = useState<"view" | "download">("download");
|
||||
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
const [shareData, setShareData] = useState<{ url: string; token: string; id: string } | null>(null);
|
||||
const [isRevoking, setIsRevoking] = useState(false);
|
||||
const [showRevokeConfirm, setShowRevokeConfirm] = useState(false);
|
||||
const [shareData, setShareData] = useState<{ url: string; token: string } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCreateShare = async () => {
|
||||
@ -48,7 +45,7 @@ export const FileShareModal: React.FC<FileShareModalProps> = ({
|
||||
});
|
||||
|
||||
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) {
|
||||
console.error("Failed to share:", error);
|
||||
} 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 () => {
|
||||
if (!shareData) return;
|
||||
try {
|
||||
@ -109,7 +90,6 @@ export const FileShareModal: React.FC<FileShareModalProps> = ({
|
||||
title="Share File"
|
||||
description={file.original_name}
|
||||
maxWidth="md"
|
||||
preventCloseOnClickOutside={showRevokeConfirm}
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
{!shareData ? (
|
||||
@ -224,33 +204,19 @@ export const FileShareModal: React.FC<FileShareModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShareData(null)}
|
||||
className="flex-1 h-10 border border-[rgba(0,0,0,0.1)] hover:bg-gray-50 rounded-xl text-xs font-bold text-[#475569] transition-all"
|
||||
>
|
||||
Create Another
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(shareData.url, '_blank')}
|
||||
className="h-10 px-4 flex items-center gap-2 border border-[rgba(0,0,0,0.1)] hover:bg-gray-50 rounded-xl text-xs font-bold text-[#475569] transition-all"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
Test Link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex gap-2">
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
{isRevoking ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
"Revoke Link (Stop Sharing)"
|
||||
)}
|
||||
Create Another
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(shareData.url, '_blank')}
|
||||
className="h-10 px-4 flex items-center gap-2 border border-[rgba(0,0,0,0.1)] hover:bg-gray-50 rounded-xl text-xs font-bold text-[#475569] transition-all"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
Test Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -263,15 +229,6 @@ export const FileShareModal: React.FC<FileShareModalProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeleteConfirmationModal
|
||||
isOpen={showRevokeConfirm}
|
||||
onClose={() => setShowRevokeConfirm(false)}
|
||||
onConfirm={handleRevokeShare}
|
||||
title="Revoke Share Link"
|
||||
message="Are you sure you want to revoke this share link? It will stop working immediately."
|
||||
isLoading={isRevoking}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
type ReactElement,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
X,
|
||||
Upload,
|
||||
@ -28,14 +29,6 @@ import {
|
||||
ChevronDown as ChevronDownIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Modal,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
FormField,
|
||||
FormSelect,
|
||||
FormTextArea,
|
||||
} from "@/components/shared";
|
||||
import fileAttachmentService, {
|
||||
type CategoriesFilterOptions,
|
||||
} from "@/services/file-attachment-service";
|
||||
@ -335,324 +328,348 @@ export const FileUploadModal = ({
|
||||
const validCount = files.filter((f) => f.status !== "blocked").length;
|
||||
const doneCount = files.filter((f) => f.status === "done").length;
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<SecondaryButton
|
||||
onClick={handleClose}
|
||||
disabled={isUploading}
|
||||
>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading || files.length === 0 || validCount === 0}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Uploading ({doneCount}/{validCount})
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
Upload {validCount > 0 ? `(${validCount})` : ""}
|
||||
</>
|
||||
)}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
);
|
||||
const content = (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-[rgba(15,23,42,0.55)] backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-2xl shadow-[0px_25px_50px_-12px_rgba(0,0,0,0.25)] w-full max-w-[500px] max-h-[92vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between px-6 pt-6 pb-4 border-b border-[rgba(0,0,0,0.08)] shrink-0">
|
||||
<div>
|
||||
<h2 className="text-[17px] font-semibold text-[#0e1b2a]">Upload New File</h2>
|
||||
<p className="text-sm text-[#9aa6b2] mt-0.5">Attach files via File Attachment Service</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isUploading}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors text-[#6b7280] disabled:opacity-40"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title="Upload New File"
|
||||
description="Attach files via File Attachment Service"
|
||||
maxWidth="md"
|
||||
footer={footer}
|
||||
>
|
||||
<div className="px-6 py-5 space-y-5">
|
||||
{/* Drop Zone */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-[#0e1b2a] mb-2 uppercase tracking-wide">Attach Files</p>
|
||||
{files.length === 0 ? (
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={onDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-xl flex flex-col items-center justify-center gap-3 py-8 cursor-pointer transition-all",
|
||||
isDragging
|
||||
? "border-[#084cc8] bg-[#084cc8]/5"
|
||||
: "border-[rgba(0,0,0,0.12)] hover:border-[#084cc8]/50 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-[#084cc8]/10 flex items-center justify-center">
|
||||
<Upload className="w-5 h-5 text-[#084cc8]" />
|
||||
</div>
|
||||
<div className="text-center text-gray-800">
|
||||
<p className="text-sm font-medium text-[#0e1b2a]">Click to upload or drag and drop</p>
|
||||
<p className="text-xs text-[#9aa6b2] mt-0.5">
|
||||
Attach supporting source files via File Attachment Service
|
||||
</p>
|
||||
<p className="text-xs text-[#9aa6b2]">PDF, DOCX, XLSX up to {MAX_SIZE_MB}MB</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={onDrop}
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-xl transition-all",
|
||||
isDragging ? "border-[#084cc8] bg-[#084cc8]/5" : "border-[rgba(0,0,0,0.08)]"
|
||||
)}
|
||||
>
|
||||
{/* Add files button */}
|
||||
{/* Scrollable body */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 space-y-5">
|
||||
|
||||
{/* Drop Zone */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-[#0e1b2a] mb-2 uppercase tracking-wide">Attach Files</p>
|
||||
{files.length === 0 ? (
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={onDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="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={cn(
|
||||
"border-2 border-dashed rounded-xl flex flex-col items-center justify-center gap-3 py-8 cursor-pointer transition-all",
|
||||
isDragging
|
||||
? "border-[#084cc8] bg-[#084cc8]/5"
|
||||
: "border-[rgba(0,0,0,0.12)] hover:border-[#084cc8]/50 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-[#084cc8] flex items-center justify-center text-white">
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
<div className="w-10 h-10 rounded-full bg-[#084cc8]/10 flex items-center justify-center">
|
||||
<Upload className="w-5 h-5 text-[#084cc8]" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-[#0e1b2a]">Click to upload or drag and drop</p>
|
||||
<p className="text-xs text-[#9aa6b2] mt-0.5">
|
||||
Attach supporting source files via File Attachment Service
|
||||
</p>
|
||||
<p className="text-xs text-[#9aa6b2]">PDF, DOCX, XLSX up to {MAX_SIZE_MB}MB</p>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-[#084cc8]">Add Files</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={onDrop}
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-xl transition-all",
|
||||
isDragging ? "border-[#084cc8] bg-[#084cc8]/5" : "border-[rgba(0,0,0,0.08)]"
|
||||
)}
|
||||
>
|
||||
{/* Add files button */}
|
||||
<div
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="flex items-center justify-center gap-2 py-3 cursor-pointer border-b border-[rgba(0,0,0,0.06)] hover:bg-gray-50 transition-all rounded-t-xl"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-[#084cc8] flex items-center justify-center">
|
||||
<Upload className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-[#084cc8]">Add Files</span>
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
<div className="divide-y divide-[rgba(0,0,0,0.06)]">
|
||||
{files.map((entry) => (
|
||||
<div key={entry.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<div className="shrink-0">{getFileIcon(entry.file.type, entry.file.name)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-[#0e1b2a] truncate max-w-[180px]">
|
||||
{entry.file.name}
|
||||
</span>
|
||||
<span className="text-xs text-[#9aa6b2] shrink-0">
|
||||
{formatBytes(entry.file.size)}
|
||||
</span>
|
||||
{entry.status === "uploading" && (
|
||||
<span className="text-xs font-semibold text-[#084cc8] shrink-0">
|
||||
{entry.progress}%
|
||||
{/* File list */}
|
||||
<div className="divide-y divide-[rgba(0,0,0,0.06)]">
|
||||
{files.map((entry) => (
|
||||
<div key={entry.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<div className="shrink-0">{getFileIcon(entry.file.type, entry.file.name)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-[#0e1b2a] truncate max-w-[180px]">
|
||||
{entry.file.name}
|
||||
</span>
|
||||
<span className="text-xs text-[#9aa6b2] shrink-0">
|
||||
{formatBytes(entry.file.size)}
|
||||
</span>
|
||||
{entry.status === "uploading" && (
|
||||
<span className="text-xs font-semibold text-[#084cc8] shrink-0">
|
||||
{entry.progress}%
|
||||
</span>
|
||||
)}
|
||||
{entry.status === "done" && (
|
||||
<span className="text-xs font-semibold text-emerald-600 shrink-0 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" /> Complete
|
||||
</span>
|
||||
)}
|
||||
{(entry.status === "blocked" || entry.status === "error") && (
|
||||
<span className="text-xs font-semibold text-red-500 shrink-0 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" /> {entry.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.status === "uploading" && (
|
||||
<div className="mt-1.5 h-1 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#084cc8] rounded-full transition-all duration-300"
|
||||
style={{ width: `${entry.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{entry.status === "done" && (
|
||||
<span className="text-xs font-semibold text-emerald-600 shrink-0 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" /> Complete
|
||||
</span>
|
||||
<div className="mt-1.5 h-1 bg-emerald-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-emerald-500 rounded-full w-full" />
|
||||
</div>
|
||||
)}
|
||||
{(entry.status === "blocked" || entry.status === "error") && (
|
||||
<span className="text-xs font-semibold text-red-500 shrink-0 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" /> {entry.error}
|
||||
</span>
|
||||
<div className="mt-1.5 h-1 bg-red-100 rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
{entry.status === "uploading" && (
|
||||
<div className="mt-1.5 h-1 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#084cc8] rounded-full transition-all duration-300"
|
||||
style={{ width: `${entry.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{entry.status === "done" && (
|
||||
<div className="mt-1.5 h-1 bg-emerald-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-emerald-500 rounded-full w-full" />
|
||||
</div>
|
||||
)}
|
||||
{(entry.status === "blocked" || entry.status === "error") && (
|
||||
<div className="mt-1.5 h-1 bg-red-100 rounded-full" />
|
||||
{entry.status !== "uploading" && entry.status !== "done" && (
|
||||
<button
|
||||
onClick={() => removeFile(entry.id)}
|
||||
className="shrink-0 p-1 rounded hover:bg-gray-100 text-[#9aa6b2]"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{entry.status !== "uploading" && entry.status !== "done" && (
|
||||
<button
|
||||
onClick={() => removeFile(entry.id)}
|
||||
className="shrink-0 p-1 rounded hover:bg-gray-100 text-[#9aa6b2]"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-[#9aa6b2] mt-2">Up to {MAX_FILES} files allowed</p>
|
||||
)}
|
||||
<p className="text-[11px] text-[#9aa6b2] mt-2">Up to {MAX_FILES} files allowed</p>
|
||||
|
||||
{uploadSuccess && (
|
||||
<div className="flex items-center gap-1.5 mt-2 text-emerald-600">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
<span className="text-xs font-semibold">Files upload successfully.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
|
||||
{/* Fields: Entity Type + Entity ID */}
|
||||
<div className="grid grid-cols-2 gap-4 pb-0">
|
||||
<FormSelect
|
||||
label="Entity Type"
|
||||
required
|
||||
value={entityType}
|
||||
onValueChange={setEntityType}
|
||||
disabled={!!defaultEntityType || isUploading}
|
||||
options={ENTITY_TYPES.map((t) => ({
|
||||
value: t,
|
||||
label: t.charAt(0).toUpperCase() + t.slice(1),
|
||||
}))}
|
||||
placeholder="Select type"
|
||||
/>
|
||||
<div className="relative">
|
||||
<FormField
|
||||
label="Entity ID"
|
||||
required
|
||||
value={entityId}
|
||||
onChange={(e) => setEntityId(e.target.value)}
|
||||
disabled={!!defaultEntityId || isUploading}
|
||||
placeholder="e.g. PRJ-1204"
|
||||
/>
|
||||
{!defaultEntityId && !isUploading && isTenantAdmin && (
|
||||
<button
|
||||
onClick={() => setEntityId(generateUUID())}
|
||||
title="Regenerate ID"
|
||||
className="absolute right-3 top-[38px] p-1 text-[#9aa6b2] hover:text-[#084cc8] transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{uploadSuccess && (
|
||||
<div className="flex items-center gap-1.5 mt-2 text-emerald-600">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
<span className="text-xs font-semibold">Files upload successfully.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Name (Editable Combobox) */}
|
||||
<div className="relative pb-4" ref={categoryDropdownRef}>
|
||||
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">
|
||||
Category Name <span className="text-[#9aa6b2] font-normal">(Select or Enter New)</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={categoryInput}
|
||||
onChange={(e) => {
|
||||
setCategoryInput(e.target.value);
|
||||
setCategorySearch(e.target.value);
|
||||
setShowCategoryDropdown(true);
|
||||
}}
|
||||
onFocus={() => setShowCategoryDropdown(true)}
|
||||
disabled={isUploading}
|
||||
placeholder="Type or select a category..."
|
||||
className="w-full h-10 border border-[rgba(0,0,0,0.12)] rounded-lg pl-3 pr-9 text-sm text-[#0e1b2a] bg-white focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8]"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-[#9aa6b2]">
|
||||
{categoryInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCategoryInput(""); setCategorySearch(""); }}
|
||||
className="p-1 hover:text-red-500"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
<ChevronDownIcon className="w-3.5 h-3.5" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
|
||||
{/* Fields: Entity Type + Entity ID */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">
|
||||
Entity Type <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={entityType}
|
||||
onChange={(e) => setEntityType(e.target.value)}
|
||||
disabled={!!defaultEntityType || isUploading}
|
||||
className="w-full h-9 border border-[rgba(0,0,0,0.12)] rounded-lg px-3 text-sm text-[#0e1b2a] bg-white focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8] disabled:bg-gray-50 disabled:text-[#9aa6b2]"
|
||||
>
|
||||
<option value="">Select type</option>
|
||||
{ENTITY_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">
|
||||
Entity ID <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative group/id">
|
||||
<input
|
||||
type="text"
|
||||
value={entityId}
|
||||
onChange={(e) => setEntityId(e.target.value)}
|
||||
disabled={!!defaultEntityId || isUploading}
|
||||
placeholder="e.g. PRJ-1204 (UUID)"
|
||||
className="w-full h-9 border border-[rgba(0,0,0,0.12)] rounded-lg pl-3 pr-9 text-sm text-[#0e1b2a] placeholder:text-[#c4cbd6] focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8] disabled:bg-gray-50 disabled:text-[#9aa6b2]"
|
||||
/>
|
||||
{!defaultEntityId && !isUploading && isTenantAdmin && (
|
||||
<button
|
||||
onClick={() => setEntityId(generateUUID())}
|
||||
title="Regenerate ID"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-[#9aa6b2] hover:text-[#084cc8] transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCategoryDropdown && !isUploading && (
|
||||
<div className="absolute top-full left-0 right-0 z-[210] mt-1 bg-white border border-[rgba(0,0,0,0.1)] shadow-xl rounded-xl py-1 max-h-[200px] overflow-y-auto custom-scrollbar">
|
||||
{categories.filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase())).length > 0 ? (
|
||||
categories
|
||||
.filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase()))
|
||||
.map((cat) => (
|
||||
<button
|
||||
key={cat.category_id ?? cat.category}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCategoryInput(cat.category);
|
||||
setShowCategoryDropdown(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm hover:bg-[#084cc8]/5 text-[#475569] hover:text-[#084cc8] transition-colors flex items-center justify-between"
|
||||
>
|
||||
{cat.category}
|
||||
{categoryInput === cat.category && <CheckCircle2 className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-2 text-xs text-[#9aa6b2] italic">
|
||||
{categorySearch ? `Press enter to use "${categorySearch}"` : "No categories found"}
|
||||
</div>
|
||||
)}
|
||||
{/* Category Name (Editable Combobox) */}
|
||||
<div className="relative" ref={categoryDropdownRef}>
|
||||
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">
|
||||
Category Name <span className="text-[#9aa6b2] font-normal">(Select or Enter New)</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={categoryInput}
|
||||
onChange={(e) => {
|
||||
setCategoryInput(e.target.value);
|
||||
setCategorySearch(e.target.value);
|
||||
setShowCategoryDropdown(true);
|
||||
}}
|
||||
onFocus={() => setShowCategoryDropdown(true)}
|
||||
disabled={isUploading}
|
||||
placeholder="Type or select a category..."
|
||||
className="w-full h-9 border border-[rgba(0,0,0,0.12)] rounded-lg pl-3 pr-9 text-sm text-[#0e1b2a] bg-white focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8]"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-[#9aa6b2]">
|
||||
{categoryInput && (
|
||||
<button
|
||||
onClick={() => { setCategoryInput(""); setCategorySearch(""); }}
|
||||
className="p-1 hover:text-red-500"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
<ChevronDownIcon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCategoryDropdown && !isUploading && (
|
||||
<div className="absolute top-full left-0 right-0 z-[210] mt-1 bg-white border border-[rgba(0,0,0,0.1)] shadow-xl rounded-xl py-1 max-h-[200px] overflow-y-auto custom-scrollbar">
|
||||
{categories.filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase())).length > 0 ? (
|
||||
categories
|
||||
.filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase()))
|
||||
.map((cat) => (
|
||||
<button
|
||||
key={cat.category_id ?? cat.category}
|
||||
onClick={() => {
|
||||
setCategoryInput(cat.category);
|
||||
setShowCategoryDropdown(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm hover:bg-[#084cc8]/5 text-[#475569] hover:text-[#084cc8] transition-colors flex items-center justify-between"
|
||||
>
|
||||
{cat.category}
|
||||
{categoryInput === cat.category && <CheckCircle2 className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-2 text-xs text-[#9aa6b2] italic">
|
||||
{categorySearch ? `Press enter to use "${categorySearch}"` : "No categories found"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">Tags</label>
|
||||
<div className="flex flex-wrap items-center gap-1.5 min-h-9 border border-[rgba(0,0,0,0.12)] rounded-lg px-2 py-1.5 focus-within:ring-2 focus-within:ring-[#084cc8]/20 focus-within:border-[#084cc8]">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 bg-gray-100 text-[#0e1b2a] text-xs font-medium rounded-md px-2 py-0.5"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => removeTag(tag)}
|
||||
disabled={isUploading}
|
||||
className="text-[#9aa6b2] hover:text-red-500"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
}}
|
||||
placeholder={tags.length === 0 ? "Add a tag..." : ""}
|
||||
disabled={isUploading}
|
||||
className="flex-1 min-w-[80px] text-sm outline-none bg-transparent placeholder:text-[#c4cbd6]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isUploading}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
placeholder="Description of this file..."
|
||||
className="w-full border border-[rgba(0,0,0,0.12)] rounded-lg px-3 py-2 text-sm text-[#0e1b2a] placeholder:text-[#c4cbd6] resize-none focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Upload error */}
|
||||
{uploadError && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-red-50 border border-red-100 px-3 py-2 text-sm text-red-600">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{uploadError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="pb-4">
|
||||
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">Tags</label>
|
||||
<div className="flex flex-wrap items-center gap-1.5 min-h-10 border border-[rgba(0,0,0,0.12)] rounded-lg px-2 py-1.5 focus-within:ring-2 focus-within:ring-[#084cc8]/20 focus-within:border-[#084cc8]">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 bg-gray-100 text-[#0e1b2a] text-xs font-medium rounded-md px-2 py-0.5"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
disabled={isUploading}
|
||||
className="text-[#9aa6b2] hover:text-red-500"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
}}
|
||||
placeholder={tags.length === 0 ? "Add a tag..." : ""}
|
||||
disabled={isUploading}
|
||||
className="flex-1 min-w-[80px] text-sm outline-none bg-transparent placeholder:text-[#c4cbd6]"
|
||||
/>
|
||||
</div>
|
||||
{/* 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>
|
||||
|
||||
{/* Description */}
|
||||
<FormTextArea
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isUploading}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
placeholder="Description of this file..."
|
||||
/>
|
||||
|
||||
{/* Upload error */}
|
||||
{uploadError && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-red-50 border border-red-100 px-3 py-2 text-sm text-red-600">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{uploadError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
export default FileUploadModal;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -3,7 +3,6 @@ import { createPortal } from 'react-dom';
|
||||
import type { ReactElement } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppTheme } from '@/hooks/useAppTheme';
|
||||
|
||||
interface FilterOption {
|
||||
value: string | string[];
|
||||
@ -31,7 +30,6 @@ export const FilterDropdown = ({
|
||||
icon,
|
||||
isSearchable = false,
|
||||
}: FilterDropdownProps): ReactElement => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [localSearch, setLocalSearch] = useState<string>('');
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
@ -135,17 +133,13 @@ export const FilterDropdown = ({
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-sm transition-all min-h-[40px] hover:bg-gray-50",
|
||||
!value && "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>}
|
||||
<span className="font-medium" style={value ? { color: primaryColor } : {}}>{label}</span>
|
||||
<span className={cn("font-medium", value && "text-[#112868]")}>{label}</span>
|
||||
{value && (
|
||||
<span
|
||||
className="font-bold text-xs bg-white px-1.5 py-0.5 rounded border ml-0.5"
|
||||
style={{ color: primaryColor, borderColor: `${primaryColor}1A` }}
|
||||
>
|
||||
<span className="text-[#112868] font-bold text-xs bg-white px-1.5 py-0.5 rounded border border-[#112868]/10 ml-0.5">
|
||||
{displayText}
|
||||
</span>
|
||||
)}
|
||||
@ -170,19 +164,7 @@ export const FilterDropdown = ({
|
||||
placeholder="Search..."
|
||||
value={localSearch}
|
||||
onChange={(e) => setLocalSearch(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-xs border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 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';
|
||||
}}
|
||||
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"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,6 @@ import { createPortal } from 'react-dom';
|
||||
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppTheme } from '@/hooks/useAppTheme';
|
||||
|
||||
interface LimitOption {
|
||||
value: string;
|
||||
@ -37,7 +36,6 @@ export const Pagination = ({
|
||||
onLimitChange,
|
||||
limitOptions = defaultLimitOptions,
|
||||
}: PaginationProps): ReactElement => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
const [isLimitOpen, setIsLimitOpen] = useState<boolean>(false);
|
||||
const limitDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const limitButtonRef = useRef<HTMLButtonElement>(null);
|
||||
@ -191,18 +189,7 @@ export const Pagination = ({
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
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]"
|
||||
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';
|
||||
}
|
||||
}}
|
||||
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]"
|
||||
>
|
||||
<span className="hidden sm:inline">Next</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ReactElement, ButtonHTMLAttributes } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppTheme } from '@/hooks/useAppTheme';
|
||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||
|
||||
const primaryButtonVariants = cva(
|
||||
'inline-flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer',
|
||||
@ -39,7 +39,31 @@ export const PrimaryButton = ({
|
||||
...props
|
||||
}: PrimaryButtonProps): ReactElement => {
|
||||
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 (
|
||||
<button
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ReactElement, ButtonHTMLAttributes } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppTheme } from '@/hooks/useAppTheme';
|
||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||
|
||||
const secondaryButtonVariants = cva(
|
||||
'inline-flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer',
|
||||
@ -32,7 +32,31 @@ export const SecondaryButton = ({
|
||||
...props
|
||||
}: SecondaryButtonProps): ReactElement => {
|
||||
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 (
|
||||
<button
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppTheme } from '@/hooks/useAppTheme';
|
||||
|
||||
const statusBadgeVariants = cva(
|
||||
'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-semibold uppercase',
|
||||
@ -37,18 +36,9 @@ export const StatusBadge = ({
|
||||
variant = 'success',
|
||||
className,
|
||||
}: StatusBadgeProps): ReactElement => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
const isInfo = variant === 'info';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(statusBadgeVariants({ variant }), className)}
|
||||
style={isInfo ? { backgroundColor: `${primaryColor}1A`, color: primaryColor } : {}}
|
||||
>
|
||||
<div
|
||||
className={cn('rounded size-1.5 shrink-0', variant && statusDotColors[variant])}
|
||||
style={isInfo ? { backgroundColor: primaryColor } : {}}
|
||||
/>
|
||||
<div className={cn(statusBadgeVariants({ variant }), className)}>
|
||||
<div className={cn('rounded size-1.5 shrink-0', variant && statusDotColors[variant])} />
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -16,7 +16,6 @@ import { Plus, Building2 } from "lucide-react";
|
||||
import { supplierService } from "@/services/supplier-service";
|
||||
import type { Supplier } from "@/types/supplier";
|
||||
import { formatDate } from "@/utils/format-date";
|
||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||
|
||||
interface SuppliersTableProps {
|
||||
tenantId?: string | null;
|
||||
@ -60,7 +59,6 @@ export const SuppliersTable = ({
|
||||
showHeader = true,
|
||||
compact = false,
|
||||
}: SuppliersTableProps): ReactElement => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -290,20 +288,7 @@ export const SuppliersTable = ({
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search suppliers..."
|
||||
className="pl-3 pr-10 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-xs focus:outline-none focus:ring-2 w-full sm:w-64 transition-all"
|
||||
style={{
|
||||
// @ts-ignore
|
||||
'--tw-ring-color': `${primaryColor}33`,
|
||||
borderColor: 'rgba(0,0,0,0.08)'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = primaryColor;
|
||||
e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}1A`;
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
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"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
|
||||
@ -24,7 +24,6 @@ import type {
|
||||
} from "@/types/department";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import type { RootState } from "@/store/store";
|
||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||
|
||||
interface DepartmentsTableProps {
|
||||
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
|
||||
@ -37,7 +36,6 @@ const DepartmentsTable = ({
|
||||
compact = false,
|
||||
showHeader = true,
|
||||
}: DepartmentsTableProps): ReactElement => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
|
||||
const effectiveTenantId = propsTenantId || reduxTenantId;
|
||||
|
||||
@ -259,20 +257,7 @@ const DepartmentsTable = ({
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search departments..."
|
||||
className="w-full pl-9 pr-4 py-2 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-2 transition-all"
|
||||
style={{
|
||||
// @ts-ignore
|
||||
'--tw-ring-color': `${primaryColor}33`,
|
||||
borderColor: 'rgba(0,0,0,0.08)'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = primaryColor;
|
||||
e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}1A`;
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
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]"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
@ -24,7 +24,6 @@ import type {
|
||||
} from "@/types/designation";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import type { RootState } from "@/store/store";
|
||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||
|
||||
interface DesignationsTableProps {
|
||||
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
|
||||
@ -37,7 +36,6 @@ const DesignationsTable = ({
|
||||
compact = false,
|
||||
showHeader = true,
|
||||
}: DesignationsTableProps): ReactElement => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
|
||||
const effectiveTenantId = propsTenantId || reduxTenantId;
|
||||
|
||||
@ -250,20 +248,7 @@ const DesignationsTable = ({
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search designations..."
|
||||
className="w-full pl-9 pr-4 py-2 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-2 transition-all"
|
||||
style={{
|
||||
// @ts-ignore
|
||||
'--tw-ring-color': `${primaryColor}33`,
|
||||
borderColor: 'rgba(0,0,0,0.08)'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = primaryColor;
|
||||
e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}1A`;
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
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]"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
@ -2,10 +2,8 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, UserPlus, Shield, Settings, Building2, BadgeCheck } from 'lucide-react';
|
||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||
import type { QuickAction } from '@/types/dashboard';
|
||||
import { useAppTheme } from '@/hooks/useAppTheme';
|
||||
|
||||
export const QuickActions = () => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
const navigate = useNavigate();
|
||||
const { roles, permissions } = useAppSelector((state) => state.auth);
|
||||
|
||||
@ -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"
|
||||
>
|
||||
<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>
|
||||
<span className="text-[11px] font-bold text-[#111827] text-center leading-none">
|
||||
{action.label}
|
||||
|
||||
@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { auditLogService } from '@/services/audit-log-service';
|
||||
import type { AuditLog } from '@/types/audit-log';
|
||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||
import { useAppTheme } from '@/hooks/useAppTheme';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||
import { StatusBadge } from '@/components/shared';
|
||||
@ -47,7 +46,6 @@ export interface RecentActivityProps {
|
||||
}
|
||||
|
||||
export const RecentActivity = ({ variant }: RecentActivityProps) => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const { tenantId } = useAppSelector((state) => state.auth);
|
||||
@ -82,8 +80,7 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[11px] font-bold gap-1 h-7"
|
||||
style={{ color: primaryColor }}
|
||||
className="text-[11px] font-bold text-[#084cc8] hover:bg-[#084cc8]/5 gap-1 h-7"
|
||||
onClick={() => navigate('/tenant/audit-logs')}
|
||||
>
|
||||
View All <ArrowRight className="w-3 h-3" />
|
||||
@ -93,7 +90,7 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
|
||||
<div className="flex-1">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 animate-spin" style={{ color: primaryColor }} />
|
||||
<Loader2 className="w-6 h-6 text-[#084cc8] animate-spin" />
|
||||
</div>
|
||||
) : auditLogs.length === 0 ? (
|
||||
<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 className="flex-1 min-w-0">
|
||||
<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 hover:underline cursor-pointer truncate"
|
||||
style={{ color: primaryColor }}
|
||||
>
|
||||
{log.resource_type}
|
||||
</span>
|
||||
<span className="text-[12px] font-bold text-[#111827]">{log.action}</span>
|
||||
<span className="text-[12px] font-bold text-[#084cc8] hover:underline cursor-pointer truncate">{log.resource_type}</span>
|
||||
</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>
|
||||
</td>
|
||||
<td className="px-5 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className="text-[12px] font-bold truncate max-w-[150px] inline-block"
|
||||
style={{ color: primaryColor }}
|
||||
>
|
||||
{log.resource_type}
|
||||
</span>
|
||||
<span className="text-[12px] font-bold text-[#084cc8] truncate max-w-[150px] inline-block">{log.resource_type}</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 whitespace-nowrap">
|
||||
<StatusBadge variant={getMethodVariant(log.request_method)}>
|
||||
|
||||
@ -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
|
||||
};
|
||||
};
|
||||
@ -13,8 +13,6 @@ import { Download, ArrowUpDown, Search } from 'lucide-react';
|
||||
import { auditLogService } from '@/services/audit-log-service';
|
||||
import { moduleService } from '@/services/module-service';
|
||||
import type { AuditLog } from '@/types/audit-log';
|
||||
import { useAppTheme } from '@/hooks/useAppTheme';
|
||||
import { PrimaryButton } from '@/components/shared';
|
||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||
|
||||
// Helper function to format date
|
||||
@ -60,7 +58,6 @@ const getStatusColor = (status: number | null): string => {
|
||||
};
|
||||
|
||||
const AuditLogs = (): ReactElement => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
const roles = useAppSelector((state) => state.auth.roles);
|
||||
const tenantId = useAppSelector((state) => state.auth.tenantId);
|
||||
const isTenantAdmin = roles?.includes('tenant_admin');
|
||||
@ -312,8 +309,7 @@ const AuditLogs = (): ReactElement => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleViewAuditLog(log.id)}
|
||||
className="text-sm font-medium transition-colors hover:opacity-80"
|
||||
style={{ color: primaryColor }}
|
||||
className="text-sm text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
@ -337,8 +333,7 @@ const AuditLogs = (): ReactElement => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleViewAuditLog(log.id)}
|
||||
className="text-sm font-medium transition-colors shrink-0 hover:opacity-80"
|
||||
style={{ color: primaryColor }}
|
||||
className="text-sm text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors shrink-0"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
@ -401,15 +396,7 @@ const AuditLogs = (): ReactElement => {
|
||||
placeholder="Search logs & metadata..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-2 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';
|
||||
}}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -485,14 +472,14 @@ const AuditLogs = (): ReactElement => {
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isTenantAdmin && (
|
||||
<PrimaryButton
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
size="small"
|
||||
className="flex items-center gap-1.5"
|
||||
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"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export</span>
|
||||
</PrimaryButton>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -535,8 +522,7 @@ const AuditLogs = (): ReactElement => {
|
||||
setSearch('');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="text-xs hover:underline decoration-offset-2"
|
||||
style={{ color: primaryColor }}
|
||||
className="text-xs text-[#ef4444] hover:underline"
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
|
||||
@ -187,10 +187,10 @@ const CreateDocument = (): ReactElement => {
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Document Service"
|
||||
// breadcrumbs={[
|
||||
// { label: "Document Service", path: "/tenant/documents" },
|
||||
// { label: "Create Document" },
|
||||
// ]}
|
||||
breadcrumbs={[
|
||||
{ label: "Document Service", path: "/tenant/documents" },
|
||||
{ label: "Create Document" },
|
||||
]}
|
||||
pageHeader={{
|
||||
title: "Create Document",
|
||||
description:
|
||||
|
||||
@ -11,7 +11,6 @@ import { QuickActions } from "@/features/dashboard/components/QuickActions";
|
||||
import { RecentActivity } from "@/features/dashboard/components/RecentActivity";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||
import { workflowService } from "@/services/workflow-service";
|
||||
import { dashboardService, type TenantDashboardStats } from "@/services/dashboard-service";
|
||||
import type { WorkflowTask } from "@/types/workflow";
|
||||
@ -33,15 +32,11 @@ const StatCard = ({
|
||||
label,
|
||||
badge
|
||||
}: StatCardProps): ReactElement => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
return (
|
||||
<div className="relative group h-full">
|
||||
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4.5 flex flex-col gap-4 shadow-sm hover:shadow-md transition-all h-full relative overflow-hidden">
|
||||
{/* Interaction Gradient */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-full h-[2px] opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{ background: `linear-gradient(to right, ${primaryColor}, #75c044, #fed314)` }}
|
||||
/>
|
||||
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-[#084cc8] via-[#75c044] to-[#fed314] opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
|
||||
<div className="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">
|
||||
@ -74,7 +69,6 @@ const StatCard = ({
|
||||
};
|
||||
|
||||
const TaskCard = ({ task }: { task: WorkflowTask }) => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
const navigate = useNavigate();
|
||||
const formatDeadline = (dueDate: string) => {
|
||||
const now = new Date();
|
||||
@ -124,10 +118,7 @@ const TaskCard = ({ task }: { task: WorkflowTask }) => {
|
||||
>
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
className="text-[11px] px-2.5 py-1.5 text-white rounded-md font-bold transition-colors shadow-sm"
|
||||
style={{ backgroundColor: primaryColor, boxShadow: `0 2px 4px ${primaryColor}33` }}
|
||||
>
|
||||
<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">
|
||||
Complete
|
||||
</button>
|
||||
</div>
|
||||
@ -199,7 +190,6 @@ const CAPASummaryChart = () => {
|
||||
*/
|
||||
|
||||
const Dashboard = (): ReactElement => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
const navigate = useNavigate();
|
||||
const [tasks, setTasks] = useState<WorkflowTask[]>([]);
|
||||
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>
|
||||
<button
|
||||
onClick={() => navigate('/tenant/workflows/tasks')}
|
||||
className="text-[11px] font-bold hover:underline"
|
||||
style={{ color: primaryColor }}
|
||||
className="text-[11px] font-bold text-[#084cc8] hover:underline"
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
|
||||
@ -12,7 +12,6 @@ import { documentService } from "@/services/document-service";
|
||||
import { moduleService } from "@/services/module-service";
|
||||
import type { DocumentCategory, DocumentSummary } from "@/types/document";
|
||||
import type { Module } from "@/types/module";
|
||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
|
||||
const formatDate = (value?: string | null): string => {
|
||||
@ -31,7 +30,6 @@ const toLabel = (value: string): string =>
|
||||
.join(" ");
|
||||
|
||||
const Documents = (): ReactElement => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
const navigate = useNavigate();
|
||||
const [documents, setDocuments] = useState<DocumentSummary[]>([]);
|
||||
const [categories, setCategories] = useState<DocumentCategory[]>([]);
|
||||
@ -111,8 +109,7 @@ const Documents = (): ReactElement => {
|
||||
render: (doc) => (
|
||||
<button
|
||||
type="button"
|
||||
className="hover:underline transition-colors"
|
||||
style={{ color: primaryColor }}
|
||||
className="text-[#084cc8] hover:underline"
|
||||
onClick={() => navigate(`/tenant/documents/${doc.id}`)}
|
||||
>
|
||||
{doc.document_number}
|
||||
@ -140,10 +137,7 @@ const Documents = (): ReactElement => {
|
||||
key: "status",
|
||||
label: "Status",
|
||||
render: (doc) => (
|
||||
<span
|
||||
className="inline-flex items-center rounded-md px-2 py-1 text-[11px]"
|
||||
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
|
||||
>
|
||||
<span className="inline-flex items-center rounded-md bg-[#112868]/10 px-2 py-1 text-[11px] text-[#112868]">
|
||||
{toLabel(doc.status)}
|
||||
</span>
|
||||
),
|
||||
@ -175,8 +169,7 @@ const Documents = (): ReactElement => {
|
||||
render: (doc) => (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs transition-colors hover:underline font-medium"
|
||||
style={{ color: primaryColor }}
|
||||
className="text-xs text-[#084cc8] hover:underline font-medium"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/tenant/documents/edit/${doc.id}`);
|
||||
@ -232,20 +225,7 @@ const Documents = (): ReactElement => {
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="Search by name, ID..."
|
||||
className="h-10 w-full pl-9 pr-3 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-2 transition-all"
|
||||
style={{
|
||||
// @ts-ignore
|
||||
'--tw-ring-color': `${primaryColor}33`,
|
||||
borderColor: 'rgba(0,0,0,0.08)'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = primaryColor;
|
||||
e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}1A`;
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -26,11 +26,9 @@ import {
|
||||
RefreshCw,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
import { FileShareModal } from "@/components/shared/FileShareModal";
|
||||
import { FileVersionUploadModal } from "@/components/shared/FileVersionUploadModal";
|
||||
import { FileShareModal } from "@/components/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
import fileAttachmentService, {
|
||||
type FileAttachment,
|
||||
@ -80,53 +78,19 @@ function copyToClipboard(text: string): void {
|
||||
// Preview component
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
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 [err, setErr] = useState(false);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [extractedHtml, setExtractedHtml] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadPreview = async () => {
|
||||
setLoading(true);
|
||||
setErr(false);
|
||||
setExtractedHtml(null);
|
||||
setPreviewUrl(undefined);
|
||||
|
||||
const isOffice = [
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.ms-powerpoint",
|
||||
].includes(file.mime_type || "");
|
||||
|
||||
try {
|
||||
if (isOffice) {
|
||||
try {
|
||||
const res = await fileAttachmentService.extractContent(file.id);
|
||||
if (res.success && (res.data.html || res.data.text)) {
|
||||
setExtractedHtml(res.data.html || res.data.text);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (extractionErr) {
|
||||
console.warn("Content extraction failed, falling back to blob preview", extractionErr);
|
||||
}
|
||||
}
|
||||
|
||||
const url = await fileAttachmentService.getPreviewUrl(file.id);
|
||||
setPreviewUrl(url);
|
||||
} catch (error) {
|
||||
console.error("Preview load failed", error);
|
||||
setErr(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadPreview();
|
||||
setLoading(true);
|
||||
setErr(false);
|
||||
fileAttachmentService
|
||||
.getPreviewUrl(file.id)
|
||||
.then((url) => setPreviewUrl(url))
|
||||
.catch(() => setErr(true))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
return () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
@ -135,6 +99,7 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
|
||||
}, [file.id]);
|
||||
|
||||
const isImage = file.mime_type?.startsWith("image/");
|
||||
const isPdf = file.mime_type === "application/pdf";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@ -144,18 +109,12 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
if (err || (!previewUrl && !extractedHtml)) {
|
||||
if (err || !previewUrl) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-[#9aa6b2]">
|
||||
<FileText className="w-16 h-16 text-gray-200" />
|
||||
<p className="text-sm">Preview not available</p>
|
||||
<p className="text-xs text-center px-4">{file.mime_type}</p>
|
||||
<button
|
||||
onClick={() => fileAttachmentService.download(file.id, file.original_name).catch(() => {})}
|
||||
className="mt-2 h-8 px-4 bg-white border border-[rgba(0,0,0,0.12)] rounded-lg text-xs font-semibold text-[#0e1b2a] hover:bg-gray-50 transition-colors shadow-sm"
|
||||
>
|
||||
Download to View
|
||||
</button>
|
||||
<p className="text-xs">{file.mime_type}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -195,30 +154,17 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
|
||||
className="max-w-full rounded-lg shadow transition-transform duration-200"
|
||||
/>
|
||||
</div>
|
||||
) : previewUrl ? (
|
||||
) : isPdf || file.mime_type?.startsWith("text/") ? (
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
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">
|
||||
<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>
|
||||
<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>
|
||||
@ -287,8 +233,7 @@ const FileView = (): ReactElement => {
|
||||
const [editingMetadata, setEditingMetadata] = useState(false);
|
||||
const [copiedChecksum, setCopiedChecksum] = useState(false);
|
||||
const [copiedPath, setCopiedPath] = useState(false);
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
|
||||
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
|
||||
// Metadata edit form
|
||||
const [draftDescription, setDraftDescription] = useState("");
|
||||
@ -407,14 +352,7 @@ const FileView = (): ReactElement => {
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsVersionModalOpen(true)}
|
||||
className="inline-flex items-center gap-2 h-9 px-3 border border-[rgba(0,0,0,0.1)] rounded-lg text-sm font-medium text-[#475569] hover:bg-gray-50 hover:border-[#084cc8]/30 transition-all font-semibold"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
New Version
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsShareModalOpen(true)}
|
||||
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"
|
||||
>
|
||||
<Share2 className="w-3.5 h-3.5" />
|
||||
@ -665,24 +603,11 @@ const FileView = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isShareModalOpen && file && (
|
||||
<FileShareModal
|
||||
isOpen={isShareModalOpen}
|
||||
onClose={() => setIsShareModalOpen(false)}
|
||||
file={file}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isVersionModalOpen && file && (
|
||||
<FileVersionUploadModal
|
||||
isOpen={isVersionModalOpen}
|
||||
onClose={() => setIsVersionModalOpen(false)}
|
||||
file={file}
|
||||
onUploaded={(newFile) => {
|
||||
navigate(`/tenant/files/${newFile.id}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<FileShareModal
|
||||
isOpen={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
file={file}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@ -19,21 +19,23 @@ import {
|
||||
Image,
|
||||
FileArchive,
|
||||
Table as TableIcon,
|
||||
MoreHorizontal,
|
||||
Download,
|
||||
Eye,
|
||||
Pencil,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
import { Pagination } from "@/components/shared";
|
||||
import { DeleteConfirmationModal } from "@/components/shared/DeleteConfirmationModal";
|
||||
import { cn } from "@/lib/utils";
|
||||
import fileAttachmentService, {
|
||||
type FileAttachment,
|
||||
type CategoriesFilterOptions,
|
||||
} from "@/services/file-attachment-service";
|
||||
import { moduleService } from "@/services/module-service";
|
||||
import { FilterDropdown, ActionDropdown } from "@/components/shared";
|
||||
import { FilterDropdown } from "@/components/shared";
|
||||
import { FileUploadModal } from "@/components/shared/FileUploadModal";
|
||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||
import { PrimaryButton } from "@/components/shared";
|
||||
import type { RootState } from "@/store/store";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@ -58,7 +60,7 @@ function formatBytes(bytes: number): string {
|
||||
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 === "application/pdf") return <FileText className="w-4 h-4 text-red-500" />;
|
||||
if (
|
||||
@ -70,7 +72,7 @@ function getFileIcon(mime: string, name: string, primaryColor: string): ReactEle
|
||||
return <TableIcon className="w-4 h-4 text-green-600" />;
|
||||
if (mime?.includes("zip") || mime?.includes("archive"))
|
||||
return <FileArchive className="w-4 h-4 text-yellow-500" />;
|
||||
return <FileText className="w-4 h-4" style={{ color: primaryColor }} />;
|
||||
return <FileText className="w-4 h-4 text-[#084cc8]" />;
|
||||
}
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
@ -140,7 +142,6 @@ function FilterPill({
|
||||
}): ReactElement {
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = options.find((o) => o.value === value);
|
||||
const { primaryColor } = useAppTheme();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@ -149,13 +150,12 @@ function FilterPill({
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 h-9 px-3 rounded-lg text-sm font-medium border transition-colors",
|
||||
value
|
||||
? "bg-opacity-5"
|
||||
: "border-[rgba(0,0,0,0.1)] bg-white text-[#475569]"
|
||||
? "border-[#084cc8] bg-[#084cc8]/5 text-[#084cc8]"
|
||||
: "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}
|
||||
{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" />
|
||||
</button>
|
||||
{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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
const FilesList = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const { primaryColor } = useAppTheme();
|
||||
const permissions = useSelector((state: RootState) => state.auth.permissions);
|
||||
|
||||
// Permission checks
|
||||
@ -226,7 +292,7 @@ const FilesList = (): ReactElement => {
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [limit, setLimit] = useState(10);
|
||||
const [limit] = useState(10);
|
||||
const offset = (currentPage - 1) * limit;
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
@ -237,9 +303,7 @@ const FilesList = (): ReactElement => {
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
|
||||
// Deleting
|
||||
const [fileToDelete, setFileToDelete] = useState<{ id: string; name: string } | null>(null);
|
||||
const [isHardDelete, setIsHardDelete] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
// ── Load categories ──
|
||||
useEffect(() => {
|
||||
@ -288,26 +352,19 @@ const FilesList = (): ReactElement => {
|
||||
fileAttachmentService.download(file.id, file.original_name).catch(() => {});
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!fileToDelete) return;
|
||||
setIsDeleting(true);
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm("Delete this file?")) return;
|
||||
setDeletingId(id);
|
||||
try {
|
||||
await fileAttachmentService.delete(fileToDelete.id, isHardDelete);
|
||||
setFileToDelete(null);
|
||||
setIsHardDelete(false);
|
||||
await fileAttachmentService.delete(id);
|
||||
await loadFiles();
|
||||
} catch (err: any) {
|
||||
alert(err?.response?.data?.error?.message || "Failed to delete file");
|
||||
} catch {
|
||||
// silence
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteConfirm = (file: FileAttachment) => {
|
||||
setFileToDelete({ id: file.id, name: file.original_name });
|
||||
setIsHardDelete(false);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch("");
|
||||
setCategoryFilter(null);
|
||||
@ -321,22 +378,22 @@ const FilesList = (): ReactElement => {
|
||||
return (
|
||||
<Layout
|
||||
currentPage="File Attachment Services"
|
||||
// breadcrumbs={[
|
||||
// { label: "File Attachment Services" },
|
||||
// { label: "File List" },
|
||||
// ]}
|
||||
breadcrumbs={[
|
||||
{ label: "File Attachment Services" },
|
||||
{ label: "File List" },
|
||||
]}
|
||||
pageHeader={{
|
||||
title: "Files List",
|
||||
description: "Manage controlled documents across their entire lifecycle.",
|
||||
action: canCreate ? (
|
||||
<PrimaryButton
|
||||
<button
|
||||
id="upload-new-file-btn"
|
||||
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 New File
|
||||
</PrimaryButton>
|
||||
</button>
|
||||
) : null,
|
||||
}}
|
||||
>
|
||||
@ -353,20 +410,7 @@ const FilesList = (): ReactElement => {
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
|
||||
placeholder="Search by name, ID..."
|
||||
className="h-9 w-[240px] pl-9 pr-3 bg-white border border-[rgba(0,0,0,0.1)] rounded-lg text-sm text-[#0e1b2a] placeholder:text-[#c4cbd6] focus:outline-none focus:ring-2"
|
||||
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';
|
||||
}}
|
||||
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]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -449,8 +493,7 @@ const FilesList = (): ReactElement => {
|
||||
{canCreate && (
|
||||
<button
|
||||
onClick={() => setShowUpload(true)}
|
||||
className="mt-2 text-sm font-medium hover:underline"
|
||||
style={{ color: primaryColor }}
|
||||
className="mt-2 text-sm font-medium text-[#084cc8] hover:underline"
|
||||
>
|
||||
Upload your first file
|
||||
</button>
|
||||
@ -468,16 +511,12 @@ const FilesList = (): ReactElement => {
|
||||
<td className="px-4 py-3 min-w-[200px]">
|
||||
<button
|
||||
onClick={() => navigate(`/tenant/files/${file.id}`)}
|
||||
className="flex items-center gap-2.5 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">
|
||||
{getFileIcon(file.mime_type, file.original_name, primaryColor)}
|
||||
{getFileIcon(file.mime_type, file.original_name)}
|
||||
</div>
|
||||
<span
|
||||
className="text-sm font-medium text-[#0e1b2a] truncate max-w-[200px]"
|
||||
onMouseEnter={(e) => e.currentTarget.style.color = primaryColor}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = '#0e1b2a'}
|
||||
>
|
||||
<span className="text-sm font-medium text-[#0e1b2a] group-hover:text-[#084cc8] truncate max-w-[200px]">
|
||||
{file.original_name}
|
||||
</span>
|
||||
</button>
|
||||
@ -555,11 +594,13 @@ const FilesList = (): ReactElement => {
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-3">
|
||||
<ActionDropdown
|
||||
<ActionMenu
|
||||
onView={() => navigate(`/tenant/files/${file.id}`)}
|
||||
onDownload={() => handleDownload(file)}
|
||||
onEdit={canUpdate ? () => navigate(`/tenant/files/${file.id}`) : undefined}
|
||||
onDelete={canDelete ? () => openDeleteConfirm(file) : undefined}
|
||||
onEdit={() => navigate(`/tenant/files/${file.id}`)}
|
||||
onDelete={() => handleDelete(file.id)}
|
||||
canEdit={canUpdate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
@ -577,7 +618,7 @@ const FilesList = (): ReactElement => {
|
||||
totalItems={total}
|
||||
limit={limit}
|
||||
onPageChange={setCurrentPage}
|
||||
onLimitChange={setLimit}
|
||||
onLimitChange={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -593,34 +634,6 @@ const FilesList = (): ReactElement => {
|
||||
}}
|
||||
isTenantAdmin={canCreate}
|
||||
/>
|
||||
|
||||
{fileToDelete && (
|
||||
<DeleteConfirmationModal
|
||||
isOpen={!!fileToDelete}
|
||||
onClose={() => setFileToDelete(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete File"
|
||||
message="Are you sure you want to delete this file"
|
||||
itemName={fileToDelete.name}
|
||||
isLoading={isDeleting}
|
||||
>
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-100 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="hard-delete-check"
|
||||
checked={isHardDelete}
|
||||
onChange={(e) => setIsHardDelete(e.target.checked)}
|
||||
className="w-4 h-4 text-red-600 focus:ring-red-500 border-red-300 rounded"
|
||||
/>
|
||||
<label htmlFor="hard-delete-check" className="text-sm font-semibold text-red-700 cursor-pointer">
|
||||
Permanent Delete (Hard Delete)
|
||||
</label>
|
||||
<p className="text-[10px] text-red-600/70 ml-1">
|
||||
{isHardDelete ? "Files will be wiped from storage." : "Files will be moved to trash."}
|
||||
</p>
|
||||
</div>
|
||||
</DeleteConfirmationModal>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@ -9,7 +9,6 @@ import {
|
||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||
import { moduleService } from '@/services/module-service';
|
||||
import type { MyModule } from '@/types/module';
|
||||
import { useAppTheme } from '@/hooks/useAppTheme';
|
||||
|
||||
// Helper function to get status badge variant
|
||||
const getStatusVariant = (status: string | null): 'success' | 'failure' | 'process' => {
|
||||
@ -27,7 +26,6 @@ const getStatusVariant = (status: string | null): 'success' | 'failure' | 'proce
|
||||
};
|
||||
|
||||
const Modules = (): ReactElement => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
const { roles, tenantId } = useAppSelector((state) => state.auth);
|
||||
const [modules, setModules] = useState<MyModule[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
@ -133,8 +131,7 @@ const Modules = (): ReactElement => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleLaunchModule(module.id)}
|
||||
className="text-sm font-medium transition-colors cursor-pointer hover:opacity-80"
|
||||
style={{ color: primaryColor }}
|
||||
className="text-sm text-[#FFC107] hover:text-[#FFC107] font-medium transition-colors cursor-pointer"
|
||||
>
|
||||
Launch
|
||||
</button>
|
||||
@ -175,8 +172,7 @@ const Modules = (): ReactElement => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleLaunchModule(module.id)}
|
||||
className="text-sm font-medium transition-colors hover:opacity-80"
|
||||
style={{ color: primaryColor }}
|
||||
className="text-sm text-[#FFC107] hover:text-[#FFC107] font-medium transition-colors"
|
||||
>
|
||||
Launch
|
||||
</button>
|
||||
|
||||
@ -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;
|
||||
@ -11,7 +11,6 @@ import { workflowService } from "@/services/workflow-service";
|
||||
import { moduleService } from "@/services/module-service";
|
||||
import type { WorkflowTask, WorkflowTaskCounts } from "@/types/workflow";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||
import { Inbox, Clock, Calendar, CheckCircle2, RotateCcw } from "lucide-react";
|
||||
|
||||
const formatDate = (value?: string | null): string => {
|
||||
@ -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={cn("w-12 h-12 rounded-lg flex items-center justify-center shrink-0", color)}
|
||||
style={style}
|
||||
>
|
||||
<div className={cn("w-12 h-12 rounded-lg flex items-center justify-center", color)}>
|
||||
<Icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
@ -41,7 +37,6 @@ const StatCard = ({ icon: Icon, label, value, color, style }: { icon: any, label
|
||||
);
|
||||
|
||||
const Tasks = (): ReactElement => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
const navigate = useNavigate();
|
||||
const [tasks, setTasks] = useState<WorkflowTask[]>([]);
|
||||
const [counts, setCounts] = useState<WorkflowTaskCounts | null>(null);
|
||||
@ -129,10 +124,7 @@ const Tasks = (): ReactElement => {
|
||||
label: "Step",
|
||||
render: (task) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-flex items-center rounded-md px-2 py-1 text-[11px] font-medium"
|
||||
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
|
||||
>
|
||||
<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">
|
||||
{task.step.name}
|
||||
</span>
|
||||
</div>
|
||||
@ -184,8 +176,7 @@ const Tasks = (): ReactElement => {
|
||||
navigate(`/tenant/documents/${task.entity.id}`);
|
||||
}
|
||||
}}
|
||||
className="font-bold text-sm transition-colors hover:opacity-80"
|
||||
style={{ color: primaryColor }}
|
||||
className="text-[#084cc8] hover:text-[#063ba1] font-bold text-sm"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
@ -210,7 +201,7 @@ const Tasks = (): ReactElement => {
|
||||
icon={Inbox}
|
||||
label="Pending Tasks"
|
||||
value={counts?.pending || 0}
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
color="bg-blue-600"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Clock}
|
||||
|
||||
@ -19,7 +19,6 @@ import { userService } from "@/services/user-service";
|
||||
import type { User } from "@/types/user";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import { usePermissions } from "@/hooks/usePermissions";
|
||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||
|
||||
// Helper function to get user initials
|
||||
const getUserInitials = (firstName: string, lastName: string): string => {
|
||||
@ -57,7 +56,6 @@ const getStatusVariant = (
|
||||
};
|
||||
|
||||
const Users = (): ReactElement => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
const { canCreate, canUpdate
|
||||
// , canDelete
|
||||
} = usePermissions();
|
||||
@ -270,8 +268,7 @@ const Users = (): ReactElement => {
|
||||
user.role_module_combinations.map((combo, idx) => (
|
||||
<span
|
||||
key={`${combo.role_id}-${combo.module_id || 'global'}-${idx}`}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium"
|
||||
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-[#112868]/10 text-[#112868]"
|
||||
title={combo.module_name ? `Role for ${combo.module_name}` : "Global Role"}
|
||||
>
|
||||
{combo.role_name} {combo.module_name && `(${combo.module_name})`}
|
||||
@ -281,8 +278,7 @@ const Users = (): ReactElement => {
|
||||
user.roles.map((role) => (
|
||||
<span
|
||||
key={role.id}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium"
|
||||
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-[#112868]/10 text-[#112868]"
|
||||
>
|
||||
{role.name}
|
||||
</span>
|
||||
|
||||
@ -19,7 +19,6 @@ import type { DocumentDetail, DocumentVersion } from "@/types/document";
|
||||
import type { WorkflowInstance } from "@/types/workflow";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||
import { Paperclip, Plus, User } from "lucide-react";
|
||||
|
||||
const formatDateTime = (value?: string | null): string => {
|
||||
@ -53,7 +52,6 @@ const ACTION_LABELS: Record<DocumentAction, string> = {
|
||||
};
|
||||
|
||||
const ViewDocument = (): ReactElement => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const [document, setDocument] = useState<DocumentDetail | null>(null);
|
||||
@ -506,8 +504,7 @@ const ViewDocument = (): ReactElement => {
|
||||
</button>
|
||||
<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"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
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"
|
||||
onClick={() => void openActionModal("submit")}
|
||||
>
|
||||
{ACTION_LABELS["submit"]}
|
||||
@ -518,8 +515,7 @@ const ViewDocument = (): ReactElement => {
|
||||
<>
|
||||
<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"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-[#112868] text-white text-xs font-medium hover:bg-[#0c1d4a]"
|
||||
onClick={() => void openWorkflowTracker()}
|
||||
>
|
||||
Workflow Status
|
||||
@ -544,8 +540,7 @@ const ViewDocument = (): ReactElement => {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md text-white text-xs font-medium hover:opacity-90"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-[#084cc8] text-white text-xs font-medium hover:bg-[#063a99]"
|
||||
onClick={() => void openActionModal("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">
|
||||
{document?.document_type || "-"}
|
||||
</span>
|
||||
<span
|
||||
className="px-2 py-1 rounded-full text-[11px] font-medium"
|
||||
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
|
||||
>
|
||||
<span className="px-2 py-1 rounded-full bg-blue-100 text-blue-700 text-[11px] font-medium">
|
||||
v{document?.current_version || "-"}
|
||||
</span>
|
||||
{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">
|
||||
<button
|
||||
type="button"
|
||||
className={`text-sm pb-2 transition-colors`}
|
||||
style={{
|
||||
color: activeTab === "overview" ? primaryColor : "#6b7280",
|
||||
borderBottom: activeTab === "overview" ? `2px solid ${primaryColor}` : "none"
|
||||
}}
|
||||
className={`text-sm pb-2 ${activeTab === "overview" ? "text-[#084cc8] border-b-2 border-[#084cc8]" : "text-[#6b7280]"}`}
|
||||
onClick={() => setActiveTab("overview")}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`text-sm pb-2 transition-colors`}
|
||||
style={{
|
||||
color: activeTab === "version-history" ? primaryColor : "#6b7280",
|
||||
borderBottom: activeTab === "version-history" ? `2px solid ${primaryColor}` : "none"
|
||||
}}
|
||||
className={`text-sm pb-2 ${activeTab === "version-history" ? "text-[#084cc8] border-b-2 border-[#084cc8]" : "text-[#6b7280]"}`}
|
||||
onClick={() => setActiveTab("version-history")}
|
||||
>
|
||||
Version History
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`text-sm pb-2 transition-colors`}
|
||||
style={{
|
||||
color: activeTab === "workflow-history" ? primaryColor : "#6b7280",
|
||||
borderBottom: activeTab === "workflow-history" ? `2px solid ${primaryColor}` : "none"
|
||||
}}
|
||||
className={`text-sm pb-2 ${activeTab === "workflow-history" ? "text-[#084cc8] border-b-2 border-[#084cc8]" : "text-[#6b7280]"}`}
|
||||
onClick={() => setActiveTab("workflow-history")}
|
||||
>
|
||||
Workflow History
|
||||
@ -721,8 +701,7 @@ const ViewDocument = (): ReactElement => {
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-white text-xs hover:opacity-90 transition-colors"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md bg-[#112868] text-white text-xs"
|
||||
onClick={() => {
|
||||
setShowNewVersionForm((prev) => !prev);
|
||||
setNewVersionContent(document.content || "");
|
||||
|
||||
@ -6,10 +6,10 @@ const WorkflowDefinationPage = (): ReactElement => {
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Workflow Definitions"
|
||||
// breadcrumbs={[
|
||||
// // { label: "Platform", path: "/tenant" },
|
||||
// { label: "Workflow Definitions" },
|
||||
// ]}
|
||||
breadcrumbs={[
|
||||
{ label: "Platform", path: "/tenant" },
|
||||
{ label: "Workflow Definitions" },
|
||||
]}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@ -19,20 +19,13 @@ const Documents = lazy(() => import("@/pages/tenant/Documents"));
|
||||
const CreateDocument = lazy(() => import("@/pages/tenant/CreateDocument"));
|
||||
const EditDocument = lazy(() => import("@/pages/tenant/EditDocument"));
|
||||
const ViewDocument = lazy(() => import("@/pages/tenant/ViewDocument"));
|
||||
const DocumentCategories = lazy(
|
||||
() => import("@/pages/tenant/DocumentCategories"),
|
||||
);
|
||||
const DocumentsDueForReview = lazy(
|
||||
() => import("@/pages/tenant/DocumentsDueForReview"),
|
||||
);
|
||||
const DocumentCategories = lazy(() => import("@/pages/tenant/DocumentCategories"));
|
||||
const DocumentsDueForReview = lazy(() => import("@/pages/tenant/DocumentsDueForReview"));
|
||||
const Tasks = lazy(() => import("@/pages/tenant/Tasks"));
|
||||
const NotificationSettings = lazy(
|
||||
() => import("@/pages/tenant/NotificationSettings"),
|
||||
);
|
||||
const NotificationSettings = lazy(() => import("@/pages/tenant/NotificationSettings"));
|
||||
const Notifications = lazy(() => import("@/pages/tenant/Notifications"));
|
||||
const FilesList = lazy(() => import("@/pages/tenant/FilesList"));
|
||||
const FileView = lazy(() => import("@/pages/tenant/FileView"));
|
||||
const StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard"));
|
||||
|
||||
// Loading fallback component
|
||||
const RouteLoader = (): ReactElement => (
|
||||
@ -147,8 +140,4 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
||||
path: "/tenant/files/:id",
|
||||
element: <LazyRoute component={FileView} />,
|
||||
},
|
||||
{
|
||||
path: "/tenant/files/storage-dashboard",
|
||||
element: <LazyRoute component={StorageDashboard} />,
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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;
|
||||
label: string;
|
||||
badge?: {
|
||||
@ -17,7 +17,7 @@ export interface ActivityLog {
|
||||
}
|
||||
|
||||
export interface QuickAction {
|
||||
icon: React.ComponentType<{ className?: string; strokeWidth?: number; color?: string; style?: React.CSSProperties }>;
|
||||
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user