feat: implement modal auto-opening via location state for table pages and enhance StorageDashboard feedback with toasts and input validation.

This commit is contained in:
SibarchanNayak 2026-07-16 15:51:15 +05:30
parent b8a4f4825a
commit e27ab25376
9 changed files with 168 additions and 73 deletions

View File

@ -6,6 +6,7 @@ import {
Settings,
Building2,
BadgeCheck,
AppWindow,
} from "lucide-react";
import { useAppSelector } from "@/hooks/redux-hooks";
import type { QuickAction } from "@/types/dashboard";
@ -35,7 +36,7 @@ export const QuickActions = () => {
btncolor: "#4C89FA",
onClick: () => navigate("/tenants/create-wizard"),
},
{ icon: UserPlus, label: "Module", btncolor: "#16C784", onClick: () => navigate("/modules") },
{ icon: AppWindow, label: "Module", btncolor: "#16C784", onClick: () => navigate("/modules") },
{
icon: Shield,
label: "Notification",
@ -55,25 +56,25 @@ export const QuickActions = () => {
icon: UserPlus,
label: "New User",
btncolor: "#4C89FA",
onClick: () => navigate("/tenant/users"),
onClick: () => navigate("/tenant/users", { state: { openCreateModal: true } }),
},
hasPermission("roles", "create") && {
icon: Shield,
label: "New Role",
btncolor: "#16C784",
onClick: () => navigate("/tenant/roles"),
onClick: () => navigate("/tenant/roles", { state: { openCreateModal: true } }),
},
hasPermission("departments", "create") && {
icon: Building2,
label: "New Dept",
btncolor: "#FCA004",
onClick: () => navigate("/tenant/departments"),
onClick: () => navigate("/tenant/departments", { state: { openCreateModal: true } }),
},
hasPermission("designations", "create") && {
icon: BadgeCheck,
label: "New Desig",
btncolor: "#6B7280",
onClick: () => navigate("/tenant/designations"),
onClick: () => navigate("/tenant/designations", { state: { openCreateModal: true } }),
},
].filter(Boolean) as QuickAction[];

View File

@ -11,18 +11,18 @@ import {
PrimaryButton,
SecondaryButton,
MultiselectPaginatedSelect,
AuthenticatedImage,
// AuthenticatedImage,
} from "@/components/shared";
import { tenantService } from "@/services/tenant-service";
import { moduleService } from "@/services/module-service";
import { fileService } from "@/services/file-service";
// import { fileService } from "@/services/file-service";
import { platformServiceApi, type PlatformService } from "@/services/platform-service";
import { showToast } from "@/utils/toast";
import {
ChevronRight,
ChevronLeft,
Image as ImageIcon,
X,
// Image as ImageIcon,
// X,
FileText,
GraduationCap,
AlertTriangle,
@ -40,7 +40,7 @@ import {
Settings,
Loader2,
} from "lucide-react";
import { generateUUID } from "@/lib/utils";
// import { generateUUID } from "@/lib/utils";
// Icon mapping helper
const serviceIconMap: Record<string, any> = {
@ -311,7 +311,8 @@ const CreateTenantWizard = (): ReactElement => {
},
});
// File upload state for branding
// File upload state for branding (commented out as company logo and favicon uploads are disabled during tenant creation)
/*
const [logoFile, setLogoFile] = useState<File | null>(null);
const [faviconFile, setFaviconFile] = useState<File | null>(null);
const [logoFileAttachmentUuid, setLogoFileAttachmentUuid] = useState<
@ -330,6 +331,7 @@ const CreateTenantWizard = (): ReactElement => {
const [isUploadingFavicon, setIsUploadingFavicon] = useState<boolean>(false);
const [logoError, setLogoError] = useState<string | null>(null);
const [faviconError, setFaviconError] = useState<string | null>(null);
*/
// Auto-generate slug and domain from name
const nameValue = tenantDetailsForm.watch("name");
@ -459,6 +461,7 @@ const CreateTenantWizard = (): ReactElement => {
}
};
/*
const handleDeleteLogo = (): void => {
if (logoPreviewUrl) {
URL.revokeObjectURL(logoPreviewUrl);
@ -494,11 +497,13 @@ const CreateTenantWizard = (): ReactElement => {
fileInput.value = "";
}
};
*/
const handleSubmit = async (): Promise<void> => {
const isValid = await settingsForm.trigger();
if (!isValid) return;
/*
// Validate logo and favicon are uploaded
setLogoError(null);
setFaviconError(null);
@ -518,6 +523,7 @@ const CreateTenantWizard = (): ReactElement => {
setCurrentStep(3); // Go to settings step where logo/favicon are
return;
}
*/
try {
setIsSubmitting(true);
@ -560,6 +566,8 @@ const CreateTenantWizard = (): ReactElement => {
};
const response = await tenantService.create(tenantData);
/*
const createdTenant = response.data;
const createdTenantId = createdTenant.id;
const slug = createdTenant.slug || tenantDetails.slug;
@ -630,6 +638,7 @@ const CreateTenantWizard = (): ReactElement => {
};
await tenantService.update(createdTenantId, updateData);
}
*/
const message = response.message || "Tenant created successfully";
showToast.success(message);
@ -672,9 +681,9 @@ const CreateTenantWizard = (): ReactElement => {
const fieldName = path.replace("settings.branding.", "");
// Map file_path fields to form fields
if (fieldName === "logo_file_path") {
setLogoError(detail.message);
// setLogoError(detail.message);
} else if (fieldName === "favicon_file_path") {
setFaviconError(detail.message);
// setFaviconError(detail.message);
} else {
settingsForm.setError(fieldName as keyof SettingsForm, {
type: "server",
@ -1288,9 +1297,9 @@ const CreateTenantWizard = (): ReactElement => {
</p>
</div>
{/* Logo and Favicon Upload */}
{/* Logo and Favicon Upload commented out since file attachment bucket can only be set after tenant creation */}
{/*
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Company Logo */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">
Company Logo <span className="text-[#e02424]">*</span>
@ -1317,19 +1326,16 @@ const CreateTenantWizard = (): ReactElement => {
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
// Clean up previous preview URL if exists
if (logoPreviewUrl) {
URL.revokeObjectURL(logoPreviewUrl);
}
// Validate file size (2MB max)
if (file.size > 2 * 1024 * 1024) {
showToast.error(
"Logo file size must be less than 2MB",
);
return;
}
// Validate file type
const validTypes = [
"image/png",
"image/svg+xml",
@ -1342,7 +1348,6 @@ const CreateTenantWizard = (): ReactElement => {
);
return;
}
// Create local preview URL immediately
const previewUrl = URL.createObjectURL(file);
setLogoFile(file);
setLogoPreviewUrl(previewUrl);
@ -1388,7 +1393,6 @@ const CreateTenantWizard = (): ReactElement => {
)}
</div>
{/* Favicon */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">
Favicon <span className="text-[#e02424]">*</span>
@ -1415,19 +1419,16 @@ const CreateTenantWizard = (): ReactElement => {
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
// Clean up previous preview URL if exists
if (faviconPreviewUrl) {
URL.revokeObjectURL(faviconPreviewUrl);
}
// Validate file size (500KB max)
if (file.size > 500 * 1024) {
showToast.error(
"Favicon file size must be less than 500KB",
);
return;
}
// Validate file type
const validTypes = [
"image/x-icon",
"image/png",
@ -1439,7 +1440,6 @@ const CreateTenantWizard = (): ReactElement => {
);
return;
}
// Create local preview URL immediately
const previewUrl = URL.createObjectURL(file);
setFaviconFile(file);
setFaviconPreviewUrl(previewUrl);
@ -1485,6 +1485,7 @@ const CreateTenantWizard = (): ReactElement => {
)}
</div>
</div>
*/}
{/* Primary Color */}
<div className="flex flex-col gap-2">

View File

@ -1,4 +1,4 @@
import { useRef, type ReactElement } from "react";
import { useRef, useEffect, type ReactElement } from "react";
import { Layout } from "@/components/layout/Layout";
import {
DepartmentsTable,
@ -7,10 +7,21 @@ import {
import { PrimaryButton } from "@/components/shared";
import { Plus } from "lucide-react";
import { usePermissions } from "@/hooks/usePermissions";
import { useLocation } from "react-router-dom";
const Departments = (): ReactElement => {
const tableRef = useRef<DepartmentsTableRef>(null);
const { canCreate } = usePermissions();
const location = useLocation();
useEffect(() => {
if (location.state?.openCreateModal) {
window.history.replaceState({}, document.title);
setTimeout(() => {
tableRef.current?.openNewModal();
}, 100);
}
}, [location.state]);
return (
<Layout

View File

@ -1,4 +1,4 @@
import { useRef, type ReactElement } from "react";
import { useRef, useEffect, type ReactElement } from "react";
import { Layout } from "@/components/layout/Layout";
import {
DesignationsTable,
@ -7,10 +7,21 @@ import {
import { PrimaryButton } from "@/components/shared";
import { Plus } from "lucide-react";
import { usePermissions } from "@/hooks/usePermissions";
import { useLocation } from "react-router-dom";
const Designations = (): ReactElement => {
const tableRef = useRef<DesignationsTableRef>(null);
const { canCreate } = usePermissions();
const location = useLocation();
useEffect(() => {
if (location.state?.openCreateModal) {
window.history.replaceState({}, document.title);
setTimeout(() => {
tableRef.current?.openNewModal();
}, 100);
}
}, [location.state]);
return (
<Layout

View File

@ -1,8 +1,21 @@
import { type ReactElement } from "react";
import { useRef, useEffect, type ReactElement } from "react";
import { Layout } from "@/components/layout/Layout";
import { RolesTable } from "@/components/superadmin";
import { RolesTable, type RolesTableRef } from "@/components/superadmin";
import { useLocation } from "react-router-dom";
const Roles = (): ReactElement => {
const tableRef = useRef<RolesTableRef>(null);
const location = useLocation();
useEffect(() => {
if (location.state?.openCreateModal) {
window.history.replaceState({}, document.title);
setTimeout(() => {
tableRef.current?.openNewModal();
}, 100);
}
}, [location.state]);
return (
<Layout
currentPage="Roles"
@ -11,7 +24,7 @@ const Roles = (): ReactElement => {
description: "Define and manage roles to control user access based on job responsibilities",
}}
>
<RolesTable showHeader={true} />
<RolesTable ref={tableRef} showHeader={true} />
</Layout>
);
};

View File

@ -29,6 +29,7 @@ import {
// SecondaryButton,
} from "@/components/shared";
import { useAppTheme } from "@/hooks/useAppTheme";
import { showToast } from "@/utils/toast";
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
@ -83,8 +84,11 @@ const QuotaEditModal = ({
});
onUpdated();
onClose();
} catch (err) {
alert("Failed to update quota");
showToast.success("Quota updated successfully");
} catch (err: any) {
showToast.error(
err?.response?.data?.error?.message || "Failed to update quota",
);
} finally {
setIsUpdating(false);
}
@ -114,22 +118,22 @@ const QuotaEditModal = ({
</>
}
>
<div className="p-6 space-y-5">
{/* <div > */}
<FormField
label="Max Total Storage (GB)"
type="number"
value={maxStorageGB}
onChange={(e) => setMaxStorageGB(parseInt(e.target.value) || 0)}
onChange={(e) => setMaxStorageGB(parseInt(e.target.value) || 1)}
placeholder="e.g. 10 for 10GB"
/>
<FormField
label="Max Per-File Size (MB)"
type="number"
value={maxFileMB}
onChange={(e) => setMaxFileMB(parseInt(e.target.value) || 0)}
onChange={(e) => setMaxFileMB(parseInt(e.target.value) || 1)}
placeholder="e.g. 50 for 50MB"
/>
</div>
{/* </div> */}
</Modal>
);
};
@ -137,7 +141,9 @@ const QuotaEditModal = ({
const StorageDashboard = (): ReactElement => {
const { primaryColor } = useAppTheme();
const [activeTab, setActiveTab] = useState<"stats" | "quota" | "cleanup">("stats");
const [activeTab, setActiveTab] = useState<"stats" | "quota" | "cleanup">(
"stats",
);
const [stats, setStats] = useState<StorageStats | null>(null);
const [quota, setQuota] = useState<StorageQuota | null>(null);
const [loading, setLoading] = useState(true);
@ -157,7 +163,8 @@ const StorageDashboard = (): ReactElement => {
setPurgeResult(null);
setPurgeError(null);
try {
const res = await fileAttachmentService.purgeSoftDeletedBlobs(olderThanHours);
const res =
await fileAttachmentService.purgeSoftDeletedBlobs(olderThanHours);
if (res.success) {
setPurgeResult(res.data.purged);
// Refresh usage stats and quota following successful purge
@ -167,7 +174,8 @@ const StorageDashboard = (): ReactElement => {
}
} catch (err: any) {
setPurgeError(
err?.response?.data?.error?.message || "Failed to purge unreferenced files."
err?.response?.data?.error?.message ||
"Failed to purge unreferenced files.",
);
console.error("Purge error:", err);
} finally {
@ -516,9 +524,7 @@ const StorageDashboard = (): ReactElement => {
<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 gap-2">
<Trash2
className="w-5 h-5 text-red-500"
/>
<Trash2 className="w-5 h-5 text-red-500" />
<h3 className="text-base font-black text-[#0e1b2a]">
Storage Maintenance & Cleanup
</h3>
@ -526,22 +532,30 @@ const StorageDashboard = (): ReactElement => {
<div className="p-6 space-y-6">
<div className="max-w-2xl">
<p className="text-sm text-[#475569] leading-relaxed">
When files are deleted, the system decrements their reference counts. Files marked as{" "}
<strong>soft-deleted with 0 active references</strong> are preserved temporarily on the storage provider
to allow background services (like AI embeddings, audits, or indexes) to finish processing them.
When files are deleted, the system decrements their
reference counts. Files marked as{" "}
<strong>soft-deleted with 0 active references</strong> are
preserved temporarily on the storage provider to allow
background services (like AI embeddings, audits, or indexes)
to finish processing them.
</p>
<p className="text-sm text-[#475569] mt-3 leading-relaxed">
Use this administrative tool to permanently purge these unreferenced physical files from the storage provider and reclaim space.
Use this administrative tool to permanently purge these
unreferenced physical files from the storage provider and
reclaim space.
</p>
</div>
<div className="border border-red-100 rounded-xl p-5 bg-red-50/30 max-w-2xl flex gap-4">
<AlertTriangle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
<div>
<h4 className="text-sm font-bold text-red-800">Warning: Permanent Action</h4>
<h4 className="text-sm font-bold text-red-800">
Warning: Permanent Action
</h4>
<p className="text-xs text-red-700 mt-1 leading-relaxed">
Purging files is irreversible. Once deleted from the physical storage provider (local disk, Azure, or AWS),
the file binaries cannot be recovered.
Purging files is irreversible. Once deleted from the
physical storage provider (local disk, Azure, or AWS), the
file binaries cannot be recovered.
</p>
</div>
</div>
@ -551,18 +565,25 @@ const StorageDashboard = (): ReactElement => {
label="Purge Files Older Than (Hours)"
type="number"
value={olderThanHours}
onChange={(e) => setOlderThanHours(Math.max(0, parseInt(e.target.value) || 0))}
onChange={(e) =>
setOlderThanHours(
Math.max(0, parseInt(e.target.value) || 0),
)
}
placeholder="e.g. 24 (0 = purge all eligible immediately)"
/>
<span className="text-[11px] text-[#9aa6b2] block -mt-2">
Set to 0 to instantly purge all unreferenced soft-deleted blobs, or specify hours (e.g., 24) to keep files deleted within that timeframe.
Set to 0 to instantly purge all unreferenced soft-deleted
blobs, or specify hours (e.g., 24) to keep files deleted
within that timeframe.
</span>
{purgeResult !== null && (
<div className="p-4 rounded-xl bg-green-50 border border-green-200 text-green-800 text-sm flex items-center gap-2">
<CheckCircle2 className="w-4.5 h-4.5 text-green-600 shrink-0" />
<span>
Successfully purged <strong>{purgeResult}</strong> orphaned file binary/binaries, freeing up storage space!
Successfully purged <strong>{purgeResult}</strong>{" "}
orphaned file binary/binaries, freeing up storage space!
</span>
</div>
)}
@ -625,10 +646,13 @@ const StorageDashboard = (): ReactElement => {
<AlertTriangle className="w-6 h-6 text-red-600" />
</div>
<div className="text-center space-y-2">
<h3 className="text-base font-bold text-gray-900">Are you absolutely sure?</h3>
<h3 className="text-base font-bold text-gray-900">
Are you absolutely sure?
</h3>
<p className="text-sm text-gray-500 leading-relaxed">
This will permanently delete all unreferenced physical file binaries older than{" "}
<strong>{olderThanHours} hours</strong> from your storage provider. This action is irreversible.
This will permanently delete all unreferenced physical file
binaries older than <strong>{olderThanHours} hours</strong> from
your storage provider. This action is irreversible.
</p>
</div>
</div>

View File

@ -137,14 +137,36 @@ const Tasks = (): ReactElement => {
key: "assignment",
label: "Assigned To",
render: (task) => {
const user = task.assignment.assigned_to_name;
const assignedUsers = task.assignment.assigned_users || [];
const roleIds = task.assignment.assigned_role_ids;
if (assignedUsers.length > 0) {
if (assignedUsers.length === 1) {
const u = assignedUsers[0];
const text = u.name ? `${u.email} (${u.name})` : u.email;
return <span className="text-gray-900 font-medium">{text}</span>;
} else {
const firstUser = assignedUsers[0];
const displayText = firstUser.name
? `${firstUser.email} (${firstUser.name}) ...`
: `${firstUser.email} ...`;
const allUsersTooltip = assignedUsers.map((u) => u.name ? `${u.email} (${u.name})` : u.email).join("\n");
return (
<span
className="text-gray-900 font-medium cursor-help border-b border-dashed border-gray-400"
title={allUsersTooltip}
>
{displayText}
</span>
);
}
}
return (
<span className="">
{user ||
(roleIds && roleIds.length > 0
{roleIds && roleIds.length > 0
? `${roleIds.length} roles`
: "-")}
: "-"}
</span>
);
},

View File

@ -1,13 +1,24 @@
import { useRef, type ReactElement } from "react";
import { useRef, useEffect, type ReactElement } from "react";
import { Layout } from "@/components/layout/Layout";
import { UsersTable, type UsersTableRef } from "@/components/superadmin";
import { PrimaryButton } from "@/components/shared";
import { Plus } from "lucide-react";
import { usePermissions } from "@/hooks/usePermissions";
import { useLocation } from "react-router-dom";
const Users = (): ReactElement => {
const tableRef = useRef<UsersTableRef>(null);
const { canCreate } = usePermissions();
const location = useLocation();
useEffect(() => {
if (location.state?.openCreateModal) {
window.history.replaceState({}, document.title);
setTimeout(() => {
tableRef.current?.openNewModal();
}, 100);
}
}, [location.state]);
return (
<Layout

View File

@ -176,6 +176,7 @@ export interface WorkflowTask {
assignment: {
assigned_user_ids?: string[] | null;
assigned_to_name: string | null;
assigned_users?: Array<{ email: string; name: string | null }> | null;
assigned_role_ids?: string[] | null;
assigned_at: string;
};