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