v2.3 updates in {tosat floder structure and svg for data not found

This commit is contained in:
MohamedHasan07
2026-07-21 16:56:53 +05:30
parent ed854fd2e3
commit 2dbcfbe924
56 changed files with 2111 additions and 1845 deletions
+195
View File
@@ -0,0 +1,195 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 560" width="100%" height="100%">
<defs>
<!-- Inner Cavity Gradient using Theme Variables -->
<linearGradient id="boxInnerGradTheme" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="var(--color-primary, #7C3AED)" stop-opacity="0.85" />
<stop offset="100%" stop-color="var(--color-primary-dark, #4C1D95)" />
</linearGradient>
<!-- Handle Gradient using Theme Variables -->
<linearGradient id="handleGradTheme" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="var(--color-primary, #7C3AED)" />
<stop offset="100%" stop-color="var(--color-primary-dark, #4C1D95)" />
</linearGradient>
<style>
.stroke-main-theme { stroke: var(--color-primary, #7C3AED); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
.stroke-thick-theme { stroke: var(--color-primary, #7C3AED); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.stroke-thin-theme { stroke: var(--color-primary-hover, #C4B5FD); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; opacity: 0.8; }
.text-title-theme { font-family: var(--font-sans, 'Inter', system-ui, sans-serif); font-weight: 700; font-size: 34px; fill: var(--color-foreground, #111827); }
.text-subtitle-theme { font-family: var(--font-sans, 'Inter', system-ui, sans-serif); font-weight: 400; font-size: 19px; fill: var(--color-muted-foreground, #6b7280); }
</style>
</defs>
<!-- 1. Background Soft Cloud/Blob -->
<g id="background-cloud">
<path d="M 230 280
C 170 270, 160 190, 220 160
C 230 100, 310 80, 360 110
C 410 70, 500 70, 540 115
C 600 90, 670 140, 650 200
C 710 240, 680 320, 610 330
C 580 380, 480 390, 420 370
C 360 400, 260 380, 230 330
C 180 330, 170 290, 230 280 Z"
fill="var(--color-primary-ghost, #EDE9FE)" opacity="0.65" />
<!-- Dotted Arc behind graphic -->
<path d="M 240 210 Q 400 90 580 200"
fill="none" stroke="var(--color-primary-light, #DDD6FE)" stroke-width="2" stroke-dasharray="4 6" opacity="0.8" />
</g>
<!-- 2. Floating Sparkles / 4-Point Stars -->
<g id="sparkles">
<!-- Top Left -->
<path d="M 220 115 Q 220 125 227 125 Q 220 125 220 135 Q 220 125 213 125 Q 220 125 220 115" fill="var(--color-primary-hover, #C4B5FD)" />
<!-- Top Right -->
<path d="M 605 105 Q 605 115 612 115 Q 605 115 605 125 Q 605 115 598 115 Q 605 115 605 105" fill="var(--color-primary-hover, #C4B5FD)" />
<!-- Mid Left -->
<path d="M 325 200 Q 325 207 330 207 Q 325 207 325 214 Q 325 207 320 207 Q 325 207 325 200" fill="var(--color-primary-hover, #C4B5FD)" />
<!-- Bottom Right -->
<path d="M 590 350 Q 590 358 596 358 Q 590 358 590 366 Q 590 358 584 358 Q 590 358 590 350" fill="var(--color-primary-hover, #C4B5FD)" />
<!-- Near Database -->
<path d="M 235 240 Q 235 245 239 245 Q 235 245 235 250 Q 235 245 231 245 Q 235 245 235 240" fill="var(--color-primary-light, #DDD6FE)" />
</g>
<!-- 3. Ground Shadow under Box -->
<g id="ground-shadow">
<ellipse cx="400" cy="425" rx="160" ry="16" fill="var(--color-primary-light, #DDD6FE)" opacity="0.6" />
<ellipse cx="400" cy="425" rx="110" ry="9" fill="var(--color-primary-hover, #C4B5FD)" opacity="0.4" />
</g>
<!-- 4. Database Cylinder Stack (Top Left) -->
<g id="database" transform="translate(240, 140) rotate(-8)">
<ellipse cx="40" cy="95" rx="35" ry="10" fill="var(--color-primary-ghost, #EDE9FE)" />
<!-- Lower Disk -->
<path d="M 5 60 L 5 78 C 5 88, 75 88, 75 78 L 75 60 Z" fill="var(--color-primary-ghost, #EDE9FE)" class="stroke-main-theme" />
<ellipse cx="40" cy="60" rx="35" ry="13" fill="var(--color-primary-light, #DDD6FE)" class="stroke-main-theme" opacity="0.9" />
<!-- Upper Disk -->
<path d="M 5 25 L 5 43 C 5 53, 75 53, 75 43 L 75 25 Z" fill="var(--color-primary-light, #DDD6FE)" class="stroke-main-theme" opacity="0.7" />
<ellipse cx="40" cy="25" rx="35" ry="13" fill="var(--color-surface, #ffffff)" class="stroke-main-theme" />
<!-- Inner Lines -->
<path d="M 15 41 C 15 46, 65 46, 65 41" fill="none" stroke="var(--color-primary, #7C3AED)" stroke-width="1.8" stroke-linecap="round" />
<path d="M 15 76 C 15 81, 65 81, 65 76" fill="none" stroke="var(--color-primary, #7C3AED)" stroke-width="1.8" stroke-linecap="round" />
</g>
<!-- 5. Folder Icon (Bottom Left) -->
<g id="folder" transform="translate(230, 240) rotate(-12)">
<!-- Back Cover -->
<path d="M 10 25 L 35 25 L 43 33 L 85 33 C 89 33, 92 36, 92 40 L 92 85 C 89 89, 89 92, 85 92 L 10 92 C 6 92, 3 89, 3 85 L 3 32 C 3 28, 6 25, 10 25 Z"
fill="var(--color-primary-light, #DDD6FE)" class="stroke-main-theme" opacity="0.8" />
<!-- Paper Sheet inside Folder -->
<rect x="12" y="15" width="70" height="65" rx="4" fill="var(--color-surface, #ffffff)" class="stroke-main-theme" />
<line x1="22" y1="28" x2="60" y2="28" stroke="var(--color-primary-hover, #C4B5FD)" stroke-width="2.5" stroke-linecap="round" />
<line x1="22" y1="36" x2="48" y2="36" stroke="var(--color-primary-hover, #C4B5FD)" stroke-width="2.5" stroke-linecap="round" />
<!-- Front Cover -->
<path d="M 0 45 L 88 40 C 92 40, 95 43, 94 47 L 85 93 C 84 97, 80 100, 76 100 L 8 100 C 4 100, 1 97, 2 93 L 0 45 Z"
fill="var(--color-primary-light, #DDD6FE)" class="stroke-main-theme" />
</g>
<!-- 6. Document Card 1 (Top Right - Image Card) -->
<g id="document-image" transform="translate(485, 120) rotate(14)">
<rect x="0" y="0" width="76" height="98" rx="7" fill="var(--color-surface, #ffffff)" class="stroke-main-theme" />
<!-- Image Box -->
<rect x="9" y="9" width="58" height="42" rx="4" fill="var(--color-primary-ghost, #EDE9FE)" stroke="var(--color-primary, #7C3AED)" stroke-width="1.8" />
<!-- Mountain landscape -->
<path d="M 12 47 L 28 28 L 42 42 L 52 32 L 64 47 Z" fill="var(--color-primary-light, #DDD6FE)" />
<circle cx="50" cy="22" r="4" fill="var(--color-primary, #7C3AED)" />
<!-- Text Lines -->
<line x1="12" y1="62" x2="64" y2="62" stroke="var(--color-primary-hover, #C4B5FD)" stroke-width="3" stroke-linecap="round" />
<line x1="12" y1="72" x2="48" y2="72" stroke="var(--color-primary-hover, #C4B5FD)" stroke-width="3" stroke-linecap="round" />
<line x1="12" y1="82" x2="56" y2="82" stroke="var(--color-primary-hover, #C4B5FD)" stroke-width="3" stroke-linecap="round" />
</g>
<!-- 7. Document Card 2 (Lower Right - Text Card with Folded Corner) -->
<g id="document-text" transform="translate(540, 190) rotate(22)">
<!-- Card Body -->
<path d="M 5 0 L 58 0 L 72 14 L 72 90 C 72 94, 69 97, 65 97 L 5 97 C 1 97, -2 94, -2 90 L -2 5 C -2 1, 1 0, 5 0 Z"
fill="var(--color-surface, #ffffff)" class="stroke-main-theme" />
<!-- Folded Corner -->
<path d="M 58 0 L 58 14 L 72 14 Z" fill="var(--color-primary-ghost, #EDE9FE)" class="stroke-main-theme" />
<!-- Text Lines -->
<line x1="8" y1="24" x2="54" y2="24" stroke="var(--color-primary-hover, #C4B5FD)" stroke-width="3" stroke-linecap="round" />
<line x1="8" y1="36" x2="60" y2="36" stroke="var(--color-primary-hover, #C4B5FD)" stroke-width="3" stroke-linecap="round" />
<line x1="8" y1="48" x2="50" y2="48" stroke="var(--color-primary-hover, #C4B5FD)" stroke-width="3" stroke-linecap="round" />
<line x1="8" y1="60" x2="58" y2="60" stroke="var(--color-primary-hover, #C4B5FD)" stroke-width="3" stroke-linecap="round" />
<line x1="8" y1="72" x2="40" y2="72" stroke="var(--color-primary-hover, #C4B5FD)" stroke-width="3" stroke-linecap="round" />
</g>
<!-- 8. Open Cardboard Box (3D Isometric Construction) -->
<g id="cardboard-box">
<!-- Back-Left Flap -->
<polygon points="290,275 265,225 375,195 400,245" fill="var(--color-primary-ghost, #EDE9FE)" class="stroke-main-theme" />
<!-- Back-Right Flap -->
<polygon points="400,245 425,195 535,225 510,275" fill="var(--color-primary-ghost, #EDE9FE)" class="stroke-main-theme" />
<!-- Inner Cavity -->
<polygon points="400,245 510,275 400,305 290,275" fill="url(#boxInnerGradTheme)" class="stroke-main-theme" />
<!-- Left Front Face -->
<polygon points="290,275 400,305 400,415 290,385" fill="var(--color-primary-light, #DDD6FE)" class="stroke-main-theme" opacity="0.9" />
<!-- Right Front Face -->
<polygon points="400,305 510,275 510,385 400,415" fill="var(--color-primary-ghost, #EDE9FE)" class="stroke-main-theme" />
<!-- Front-Left Flap -->
<polygon points="290,275 265,325 375,355 400,305" fill="var(--color-primary-ghost, #EDE9FE)" class="stroke-main-theme" opacity="0.95" />
<!-- Front-Right Flap -->
<polygon points="400,305 425,355 535,325 510,275" fill="var(--color-primary-ghost, #EDE9FE)" class="stroke-main-theme" />
<!-- Shipping Label / Sticker on Left Front Face -->
<g transform="translate(320, 328) skewY(15.2)">
<rect x="0" y="0" width="36" height="24" rx="2.5" fill="var(--color-surface, #ffffff)" stroke="var(--color-primary, #7C3AED)" stroke-width="1.5" />
<line x1="6" y1="8" x2="30" y2="8" stroke="var(--color-primary-hover, #C4B5FD)" stroke-width="2" stroke-linecap="round" />
<line x1="6" y1="15" x2="22" y2="15" stroke="var(--color-primary-hover, #C4B5FD)" stroke-width="2" stroke-linecap="round" />
</g>
<!-- Details on Right Front Face -->
<g transform="translate(425, 342) skewY(-15.2)">
<line x1="0" y1="0" x2="20" y2="0" stroke="var(--color-primary-hover, #C4B5FD)" stroke-width="2" stroke-linecap="round" />
<line x1="0" y1="7" x2="20" y2="7" stroke="var(--color-primary-hover, #C4B5FD)" stroke-width="2" stroke-linecap="round" />
<line x1="0" y1="14" x2="13" y2="14" stroke="var(--color-primary-hover, #C4B5FD)" stroke-width="2" stroke-linecap="round" />
<!-- Soft tint patch on right face -->
<rect x="28" y="-4" width="24" height="20" rx="2" fill="var(--color-primary-light, #DDD6FE)" stroke="var(--color-primary, #7C3AED)" stroke-width="1.2" opacity="0.7" />
</g>
</g>
<!-- 9. Floating Magnifying Glass (Center Top) -->
<g id="magnifying-glass">
<!-- Handle -->
<path d="M 435 190 L 485 240" stroke="url(#handleGradTheme)" stroke-width="22" stroke-linecap="round" />
<path d="M 470 225 L 485 240" stroke="var(--color-primary-dark, #4C1D95)" stroke-width="22" stroke-linecap="round" opacity="0.3" />
<!-- Lens Outer Rings -->
<circle cx="395" cy="148" r="62" fill="none" stroke="var(--color-primary, #7C3AED)" stroke-width="7" />
<circle cx="395" cy="148" r="56" fill="none" stroke="var(--color-primary-hover, #C4B5FD)" stroke-width="4" />
<!-- Lens Glass -->
<circle cx="395" cy="148" r="54" fill="var(--color-surface, #ffffff)" stroke="var(--color-primary-ghost, #EDE9FE)" stroke-width="2" />
<!-- Reflection Arc -->
<path d="M 352 135 A 46 46 0 0 1 420 108" fill="none" stroke="var(--color-primary-ghost, #EDE9FE)" stroke-width="4" stroke-linecap="round" />
<!-- Exclamation Mark ! -->
<path d="M 389 114 C 389 110, 401 110, 401 114 L 398 150 C 398 153, 392 153, 392 150 Z" fill="var(--color-primary, #7C3AED)" />
<circle cx="395" cy="167" r="6" fill="var(--color-primary, #7C3AED)" />
</g>
<!-- 10. Text Labels -->
<g id="text-labels">
<text x="400" y="475" text-anchor="middle" class="text-title-theme">No Data Found</text>
<text x="400" y="515" text-anchor="middle" class="text-subtitle-theme">There are no records to display.</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

+7 -3
View File
@@ -1,4 +1,5 @@
import { useState, useMemo, useRef, useEffect, type ReactNode } from "react";
import { EmptyStateIllustration } from "./EmptyStateIllustration";
import {
Search, X, ChevronsUpDown, ChevronUp, ChevronDown,
ChevronLeft, ChevronRight, Settings2, Check, Filter
@@ -37,6 +38,7 @@ export interface DataTableProps<T = any> {
onView?: (row: T) => void;
onEdit?: (row: T) => void;
onDelete?: (row: T) => void;
canDelete?: (row: T) => boolean;
customActions?: (row: T) => CustomAction[];
};
}
@@ -344,8 +346,10 @@ export function DataTable<T extends Record<string, any> = any>({
</tr>
) : paged.length === 0 ? (
<tr>
<td colSpan={colSpan} className="px-6 py-14 text-center text-muted-foreground">
No records found
<td colSpan={colSpan} className="px-6 text-center">
<div className="flex flex-col items-center justify-center">
<EmptyStateIllustration className="w-full max-w-sm h-auto opacity-90" />
</div>
</td>
</tr>
) : (
@@ -388,7 +392,7 @@ export function DataTable<T extends Record<string, any> = any>({
<ActionMenu
onView={actionConfig.onView ? () => actionConfig.onView!(row) : undefined}
onEdit={actionConfig.onEdit ? () => actionConfig.onEdit!(row) : undefined}
onDelete={actionConfig.onDelete ? () => actionConfig.onDelete!(row) : undefined}
onDelete={(actionConfig.onDelete && (actionConfig.canDelete ? actionConfig.canDelete(row) : true)) ? () => actionConfig.onDelete!(row) : undefined}
customActions={actionConfig.customActions ? actionConfig.customActions(row) : undefined}
/>
</td>
+11 -5
View File
@@ -1,11 +1,12 @@
import React from "react";
import { Inbox } from "lucide-react";
import { EmptyStateIllustration } from "./EmptyStateIllustration";
interface EmptyStateProps {
title?: string;
description?: string;
action?: React.ReactNode;
icon?: React.ReactNode;
illustration?: boolean;
}
export function EmptyState({
@@ -13,12 +14,17 @@ export function EmptyState({
description = "There are no items to display.",
action,
icon,
illustration = true,
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-14 h-14 rounded-2xl bg-surface-muted flex items-center justify-center mb-4">
{icon ?? <Inbox className="w-7 h-7 text-muted-foreground" />}
</div>
<div className="flex flex-col items-center justify-center py-10 text-center">
{icon ? (
<div className="w-14 h-14 rounded-2xl bg-surface-muted flex items-center justify-center mb-4">
{icon}
</div>
) : illustration ? (
<EmptyStateIllustration className="w-full max-w-sm h-auto mb-4 opacity-90" />
) : null}
<h3 className="text-base font-semibold text-foreground mb-1">{title}</h3>
<p className="text-sm text-muted-foreground max-w-sm mb-6">{description}</p>
{action && <div>{action}</div>}
@@ -0,0 +1,229 @@
export function EmptyStateIllustration({ className }: { className?: string }) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 800 560"
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="No Data Found"
>
<defs>
{/* Inner Cavity Gradient using Theme Variables */}
<linearGradient id="boxInnerGradThemeComp" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="var(--color-primary, #7C3AED)" stopOpacity="0.85" />
<stop offset="100%" stopColor="var(--color-primary-dark, #4C1D95)" />
</linearGradient>
{/* Handle Gradient using Theme Variables */}
<linearGradient id="handleGradThemeComp" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="var(--color-primary, #7C3AED)" />
<stop offset="100%" stopColor="var(--color-primary-dark, #4C1D95)" />
</linearGradient>
<style>{`
.stroke-main-theme { stroke: var(--color-primary, #7C3AED); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
.stroke-thick-theme { stroke: var(--color-primary, #7C3AED); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.stroke-thin-theme { stroke: var(--color-primary-hover, #C4B5FD); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; opacity: 0.8; }
.text-title-theme { font-family: var(--font-sans, 'Inter', system-ui, sans-serif); font-weight: 700; font-size: 34px; fill: var(--color-foreground, #111827); }
.text-subtitle-theme { font-family: var(--font-sans, 'Inter', system-ui, sans-serif); font-weight: 400; font-size: 19px; fill: var(--color-muted-foreground, #6b7280); }
`}</style>
</defs>
{/* 1. Background Soft Cloud/Blob */}
<g id="background-cloud">
<path
d="M 230 280
C 170 270, 160 190, 220 160
C 230 100, 310 80, 360 110
C 410 70, 500 70, 540 115
C 600 90, 670 140, 650 200
C 710 240, 680 320, 610 330
C 580 380, 480 390, 420 370
C 360 400, 260 380, 230 330
C 180 330, 170 290, 230 280 Z"
fill="var(--color-primary-ghost, #EDE9FE)"
opacity="0.65"
/>
{/* Dotted Arc behind graphic */}
<path
d="M 240 210 Q 400 90 580 200"
fill="none"
stroke="var(--color-primary-light, #DDD6FE)"
strokeWidth="2"
strokeDasharray="4 6"
opacity="0.8"
/>
</g>
{/* 2. Floating Sparkles / 4-Point Stars */}
<g id="sparkles">
{/* Top Left */}
<path d="M 220 115 Q 220 125 227 125 Q 220 125 220 135 Q 220 125 213 125 Q 220 125 220 115" fill="var(--color-primary-hover, #C4B5FD)" />
{/* Top Right */}
<path d="M 605 105 Q 605 115 612 115 Q 605 115 605 125 Q 605 115 598 115 Q 605 115 605 105" fill="var(--color-primary-hover, #C4B5FD)" />
{/* Mid Left */}
<path d="M 325 200 Q 325 207 330 207 Q 325 207 325 214 Q 325 207 320 207 Q 325 207 325 200" fill="var(--color-primary-hover, #C4B5FD)" />
{/* Bottom Right */}
<path d="M 590 350 Q 590 358 596 358 Q 590 358 590 366 Q 590 358 584 358 Q 590 358 590 350" fill="var(--color-primary-hover, #C4B5FD)" />
{/* Near Database */}
<path d="M 235 240 Q 235 245 239 245 Q 235 245 235 250 Q 235 245 231 245 Q 235 245 235 240" fill="var(--color-primary-light, #DDD6FE)" />
</g>
{/* 3. Ground Shadow under Box */}
<g id="ground-shadow">
<ellipse cx="400" cy="425" rx="160" ry="16" fill="var(--color-primary-light, #DDD6FE)" opacity="0.6" />
<ellipse cx="400" cy="425" rx="110" ry="9" fill="var(--color-primary-hover, #C4B5FD)" opacity="0.4" />
</g>
{/* 4. Database Cylinder Stack (Top Left) */}
<g id="database" transform="translate(240, 140) rotate(-8)">
<ellipse cx="40" cy="95" rx="35" ry="10" fill="var(--color-primary-ghost, #EDE9FE)" />
{/* Lower Disk */}
<path d="M 5 60 L 5 78 C 5 88, 75 88, 75 78 L 75 60 Z" fill="var(--color-primary-ghost, #EDE9FE)" className="stroke-main-theme" />
<ellipse cx="40" cy="60" rx="35" ry="13" fill="var(--color-primary-light, #DDD6FE)" className="stroke-main-theme" opacity="0.9" />
{/* Upper Disk */}
<path d="M 5 25 L 5 43 C 5 53, 75 53, 75 43 L 75 25 Z" fill="var(--color-primary-light, #DDD6FE)" className="stroke-main-theme" opacity="0.7" />
<ellipse cx="40" cy="25" rx="35" ry="13" fill="var(--color-surface, #ffffff)" className="stroke-main-theme" />
{/* Inner Lines */}
<path d="M 15 41 C 15 46, 65 46, 65 41" fill="none" stroke="var(--color-primary, #7C3AED)" strokeWidth="1.8" strokeLinecap="round" />
<path d="M 15 76 C 15 81, 65 81, 65 76" fill="none" stroke="var(--color-primary, #7C3AED)" strokeWidth="1.8" strokeLinecap="round" />
</g>
{/* 5. Folder Icon (Bottom Left) */}
<g id="folder" transform="translate(230, 240) rotate(-12)">
{/* Back Cover */}
<path
d="M 10 25 L 35 25 L 43 33 L 85 33 C 89 33, 92 36, 92 40 L 92 85 C 89 89, 89 92, 85 92 L 10 92 C 6 92, 3 89, 3 85 L 3 32 C 3 28, 6 25, 10 25 Z"
fill="var(--color-primary-light, #DDD6FE)"
className="stroke-main-theme"
opacity="0.8"
/>
{/* Paper Sheet inside Folder */}
<rect x="12" y="15" width="70" height="65" rx="4" fill="var(--color-surface, #ffffff)" className="stroke-main-theme" />
<line x1="22" y1="28" x2="60" y2="28" stroke="var(--color-primary-hover, #C4B5FD)" strokeWidth="2.5" strokeLinecap="round" />
<line x1="22" y1="36" x2="48" y2="36" stroke="var(--color-primary-hover, #C4B5FD)" strokeWidth="2.5" strokeLinecap="round" />
{/* Front Cover */}
<path
d="M 0 45 L 88 40 C 92 40, 95 43, 94 47 L 85 93 C 84 97, 80 100, 76 100 L 8 100 C 4 100, 1 97, 2 93 L 0 45 Z"
fill="var(--color-primary-light, #DDD6FE)"
className="stroke-main-theme"
/>
</g>
{/* 6. Document Card 1 (Top Right - Image Card) */}
<g id="document-image" transform="translate(485, 120) rotate(14)">
<rect x="0" y="0" width="76" height="98" rx="7" fill="var(--color-surface, #ffffff)" className="stroke-main-theme" />
{/* Image Box */}
<rect x="9" y="9" width="58" height="42" rx="4" fill="var(--color-primary-ghost, #EDE9FE)" stroke="var(--color-primary, #7C3AED)" strokeWidth="1.8" />
{/* Mountain landscape */}
<path d="M 12 47 L 28 28 L 42 42 L 52 32 L 64 47 Z" fill="var(--color-primary-light, #DDD6FE)" />
<circle cx="50" cy="22" r="4" fill="var(--color-primary, #7C3AED)" />
{/* Text Lines */}
<line x1="12" y1="62" x2="64" y2="62" stroke="var(--color-primary-hover, #C4B5FD)" strokeWidth="3" strokeLinecap="round" />
<line x1="12" y1="72" x2="48" y2="72" stroke="var(--color-primary-hover, #C4B5FD)" strokeWidth="3" strokeLinecap="round" />
<line x1="12" y1="82" x2="56" y2="82" stroke="var(--color-primary-hover, #C4B5FD)" strokeWidth="3" strokeLinecap="round" />
</g>
{/* 7. Document Card 2 (Lower Right - Text Card with Folded Corner) */}
<g id="document-text" transform="translate(540, 190) rotate(22)">
{/* Card Body */}
<path
d="M 5 0 L 58 0 L 72 14 L 72 90 C 72 94, 69 97, 65 97 L 5 97 C 1 97, -2 94, -2 90 L -2 5 C -2 1, 1 0, 5 0 Z"
fill="var(--color-surface, #ffffff)"
className="stroke-main-theme"
/>
{/* Folded Corner */}
<path d="M 58 0 L 58 14 L 72 14 Z" fill="var(--color-primary-ghost, #EDE9FE)" className="stroke-main-theme" />
{/* Text Lines */}
<line x1="8" y1="24" x2="54" y2="24" stroke="var(--color-primary-hover, #C4B5FD)" strokeWidth="3" strokeLinecap="round" />
<line x1="8" y1="36" x2="60" y2="36" stroke="var(--color-primary-hover, #C4B5FD)" strokeWidth="3" strokeLinecap="round" />
<line x1="8" y1="48" x2="50" y2="48" stroke="var(--color-primary-hover, #C4B5FD)" strokeWidth="3" strokeLinecap="round" />
<line x1="8" y1="60" x2="58" y2="60" stroke="var(--color-primary-hover, #C4B5FD)" strokeWidth="3" strokeLinecap="round" />
<line x1="8" y1="72" x2="40" y2="72" stroke="var(--color-primary-hover, #C4B5FD)" strokeWidth="3" strokeLinecap="round" />
</g>
{/* 8. Open Cardboard Box (3D Isometric Construction) */}
<g id="cardboard-box">
{/* Back-Left Flap */}
<polygon points="290,275 265,225 375,195 400,245" fill="var(--color-primary-ghost, #EDE9FE)" className="stroke-main-theme" />
{/* Back-Right Flap */}
<polygon points="400,245 425,195 535,225 510,275" fill="var(--color-primary-ghost, #EDE9FE)" className="stroke-main-theme" />
{/* Inner Cavity */}
<polygon points="400,245 510,275 400,305 290,275" fill="url(#boxInnerGradThemeComp)" className="stroke-main-theme" />
{/* Left Front Face */}
<polygon points="290,275 400,305 400,415 290,385" fill="var(--color-primary-light, #DDD6FE)" className="stroke-main-theme" opacity="0.9" />
{/* Right Front Face */}
<polygon points="400,305 510,275 510,385 400,415" fill="var(--color-primary-ghost, #EDE9FE)" className="stroke-main-theme" />
{/* Front-Left Flap */}
<polygon points="290,275 265,325 375,355 400,305" fill="var(--color-primary-ghost, #EDE9FE)" className="stroke-main-theme" opacity="0.95" />
{/* Front-Right Flap */}
<polygon points="400,305 425,355 535,325 510,275" fill="var(--color-primary-ghost, #EDE9FE)" className="stroke-main-theme" />
{/* Shipping Label / Sticker on Left Front Face */}
<g transform="translate(320, 328) skewY(15.2)">
<rect x="0" y="0" width="36" height="24" rx="2.5" fill="var(--color-surface, #ffffff)" stroke="var(--color-primary, #7C3AED)" strokeWidth="1.5" />
<line x1="6" y1="8" x2="30" y2="8" stroke="var(--color-primary-hover, #C4B5FD)" strokeWidth="2" strokeLinecap="round" />
<line x1="6" y1="15" x2="22" y2="15" stroke="var(--color-primary-hover, #C4B5FD)" strokeWidth="2" strokeLinecap="round" />
</g>
{/* Details on Right Front Face */}
<g transform="translate(425, 342) skewY(-15.2)">
<line x1="0" y1="0" x2="20" y2="0" stroke="var(--color-primary-hover, #C4B5FD)" strokeWidth="2" strokeLinecap="round" />
<line x1="0" y1="7" x2="20" y2="7" stroke="var(--color-primary-hover, #C4B5FD)" strokeWidth="2" strokeLinecap="round" />
<line x1="0" y1="14" x2="13" y2="14" stroke="var(--color-primary-hover, #C4B5FD)" strokeWidth="2" strokeLinecap="round" />
{/* Soft tint patch on right face */}
<rect x="28" y="-4" width="24" height="20" rx="2" fill="var(--color-primary-light, #DDD6FE)" stroke="var(--color-primary, #7C3AED)" strokeWidth="1.2" opacity="0.7" />
</g>
</g>
{/* 9. Floating Magnifying Glass (Center Top) */}
<g id="magnifying-glass">
{/* Handle */}
<path d="M 435 190 L 485 240" stroke="url(#handleGradThemeComp)" strokeWidth="22" strokeLinecap="round" />
<path d="M 470 225 L 485 240" stroke="var(--color-primary-dark, #4C1D95)" strokeWidth="22" strokeLinecap="round" opacity="0.3" />
{/* Lens Outer Rings */}
<circle cx="395" cy="148" r="62" fill="none" stroke="var(--color-primary, #7C3AED)" strokeWidth="7" />
<circle cx="395" cy="148" r="56" fill="none" stroke="var(--color-primary-hover, #C4B5FD)" strokeWidth="4" />
{/* Lens Glass */}
<circle cx="395" cy="148" r="54" fill="var(--color-surface, #ffffff)" stroke="var(--color-primary-ghost, #EDE9FE)" strokeWidth="2" />
{/* Reflection Arc */}
<path d="M 352 135 A 46 46 0 0 1 420 108" fill="none" stroke="var(--color-primary-ghost, #EDE9FE)" strokeWidth="4" strokeLinecap="round" />
{/* Exclamation Mark ! */}
<path d="M 389 114 C 389 110, 401 110, 401 114 L 398 150 C 398 153, 392 153, 392 150 Z" fill="var(--color-primary, #7C3AED)" />
<circle cx="395" cy="167" r="6" fill="var(--color-primary, #7C3AED)" />
</g>
{/* 10. Text Labels */}
<g id="text-labels">
<text x="400" y="475" textAnchor="middle" className="text-title-theme">
No Data Found
</text>
<text x="400" y="515" textAnchor="middle" className="text-subtitle-theme">
There are no records to display.
</text>
</g>
</svg>
);
}
+129 -42
View File
@@ -1,13 +1,12 @@
import { useNavigate } from "react-router-dom";
import { Bell, ChevronDown, Building2, Globe, LogOut } from "lucide-react";
import { Bell, ChevronDown, Building2, Globe, LogOut, Shield, User, Settings, CheckCircle2, ChevronRight } from "lucide-react";
import { useLanguage, type Language } from "../../contexts/LanguageContext";
import { useHeader } from "../../contexts/HeaderContext";
import { useAppDispatch, useAppSelector } from "../../store";
import { logout } from "../../store/slices/authSlice";
import { useState, useRef, useEffect } from "react";
import { notificationService } from "../../features/notifications";
import { notify } from "../../services/toast";
export function Header() {
const { title, subtitle } = useHeader();
@@ -20,6 +19,17 @@ export function Header() {
const [headerUnread, setHeaderUnread] = useState(0);
const menuRef = useRef<HTMLDivElement>(null);
// Derive real-time display values from Redux user state
const userName = user?.name || user?.user_name || (user?.email ? user.email.split('@')[0] : "User");
const userEmail = user?.email || "user@pim.com";
const userInitials = userName.slice(0, 2).toUpperCase();
const roleName = user?.roles?.[0]?.name || user?.roles?.[0]?.role_name || user?.role_name ||
(user?.type === 'platform' || user?.email === 'admin@admin.com' ? "Super Admin" : "Member");
const tenantName = user?.tenant?.name || user?.tenant_name ||
(user?.type === 'platform' || !user?.tenant_id ? "Platform Core" : `Tenant #${user.tenant_id}`);
useEffect(() => {
let mounted = true;
const sync = async () => {
@@ -28,18 +38,15 @@ export function Header() {
};
sync();
// Listen to socket for real-time updates
const handleUnreadCount = (data: { unreadCount: number }) => {
if (mounted) setHeaderUnread(data.unreadCount);
};
// The socket might not be immediately connected here depending on when Headers renders vs useNotifications
// But socketService caches listeners and binds them when connected.
import("../../services/socket.service").then(({ socketService }) => {
socketService.on("notification:unread-count", handleUnreadCount);
});
const id = setInterval(sync, 60_000); // Polling fallback
const id = setInterval(sync, 60_000);
return () => {
mounted = false;
clearInterval(id);
@@ -61,6 +68,7 @@ export function Header() {
const handleLogout = () => {
dispatch(logout());
notify.info("Logged out successfully");
navigate("/login");
};
@@ -79,75 +87,154 @@ export function Header() {
{/* Right Side Controls */}
<div className="flex items-center gap-3">
{/* Language Selector */}
<div className="flex items-center gap-2 rounded-lg border border-border px-3 py-1.5 hover:bg-surface-muted transition-colors">
<Globe className="w-4 h-4 text-muted-foreground" />
<select
value={language}
onChange={(e) => setLanguage(e.target.value as Language)}
className="text-sm font-medium bg-transparent outline-none cursor-pointer"
className="text-sm font-medium bg-transparent outline-none cursor-pointer text-foreground"
aria-label="Select language"
>
<option value="en">English</option>
<option value="hi">Hindi</option>
<option value="ar">Arabic</option>
<option value="fr">French</option>
<option value="es">Spanish</option>
<option value="de">German</option>
<option value="en" className="bg-surface text-foreground">English</option>
<option value="hi" className="bg-surface text-foreground">Hindi</option>
<option value="ar" className="bg-surface text-foreground">Arabic</option>
<option value="fr" className="bg-surface text-foreground">French</option>
<option value="es" className="bg-surface text-foreground">Spanish</option>
<option value="de" className="bg-surface text-foreground">German</option>
</select>
</div>
<button className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium border border-border hover:bg-surface-muted">
<Building2 className="w-4 h-4" />
<span>Acme Corp</span>
<ChevronDown className="w-3 h-3" />
</button>
{/* Real-time Tenant Indicator Button */}
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium border border-border bg-surface-muted text-foreground">
<Building2 className="w-4 h-4 text-primary" />
<span>{tenantName}</span>
</div>
{/* Notifications Bell */}
<div className="relative">
<button
onClick={() => navigate("/notifications")}
className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-muted"
className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-muted transition-colors relative"
aria-label="View notifications"
>
<Bell className="w-4 h-4" />
<Bell className="w-4 h-4 text-foreground" />
{headerUnread > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-primary text-white text-[10px] rounded-full flex items-center justify-center">
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 bg-primary text-white text-[10px] font-extrabold rounded-full flex items-center justify-center shadow-xs">
{headerUnread > 9 ? "9+" : headerUnread}
</span>
)}
</button>
</div>
{/* Profile Card Trigger & Popover */}
<div className="relative" ref={menuRef}>
<button
onClick={() => setShowProfileMenu(!showProfileMenu)}
className="flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-lg hover:bg-surface-muted"
className="flex items-center gap-2.5 pl-2 pr-3 py-1.5 rounded-xl hover:bg-surface-muted transition-all border border-transparent hover:border-border cursor-pointer"
>
<div className="w-7 h-7 rounded-full bg-linear-to-br from-primary to-primary-hover text-white text-xs font-bold flex items-center justify-center">
{user?.name
? user.name.substring(0, 2).toUpperCase()
: user?.email
? user.email.substring(0, 2).toUpperCase()
: "U"}
<div className="w-8 h-8 rounded-full bg-linear-to-br from-primary to-primary-hover text-white text-xs font-black flex items-center justify-center shadow-xs">
{userInitials}
</div>
<div className="text-left">
<div className="text-sm font-semibold">
{user?.name || user?.email || "User"}
<div className="text-left hidden sm:block">
<div className="text-xs font-bold text-foreground leading-tight">
{userName}
</div>
<div className="text-xs text-muted-foreground -mt-0.5">
{user?.roles?.[0]?.name || user?.type || "Member"}
<div className="text-[10px] text-muted-foreground font-medium truncate max-w-[110px]">
{roleName}
</div>
</div>
<ChevronDown className={`w-3.5 h-3.5 text-muted-foreground transition-transform duration-200 ${showProfileMenu ? 'rotate-180' : ''}`} />
</button>
{/* Profile Card Popover (Senior Dev Card UI with Real-time Data) */}
{showProfileMenu && (
<div className="absolute right-0 mt-2 w-48 bg-surface rounded-xl shadow-lg border border-border py-1 z-50">
<button
onClick={handleLogout}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 transition-colors"
>
<LogOut className="w-4 h-4" />
Sign Out
</button>
<div className="absolute right-0 mt-2.5 w-80 bg-surface rounded-2xl shadow-2xl border border-border p-4 z-50 animate-fade-in divide-y divide-border">
{/* User Header */}
<div className="pb-3.5 flex items-start gap-3">
<div className="relative shrink-0">
<div className="w-11 h-11 rounded-full bg-linear-to-br from-primary to-primary-hover text-white text-sm font-black flex items-center justify-center shadow-sm">
{userInitials}
</div>
<span className="absolute bottom-0 right-0 w-3 h-3 bg-emerald-500 border-2 border-surface rounded-full" title="Online" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-bold text-foreground text-sm truncate" title={userName}>
{userName}
</h4>
<p className="text-xs text-muted-foreground font-mono truncate" title={userEmail}>
{userEmail}
</p>
<div className="mt-1 flex items-center gap-1">
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200">
<CheckCircle2 className="w-2.5 h-2.5" />
Active Session
</span>
</div>
</div>
</div>
{/* Role & Tenant Real-time Badges */}
<div className="py-3 space-y-2">
{/* Real-time Role Badge */}
<div className="flex items-center justify-between text-xs bg-surface-muted p-2.5 rounded-xl border border-border">
<span className="text-muted-foreground font-medium flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-primary" />
Role
</span>
<span className="font-bold text-primary-dark bg-primary/10 px-2 py-0.5 rounded-lg border border-primary/20 text-[11px] truncate max-w-[140px]">
{roleName}
</span>
</div>
{/* Real-time Tenant Badge */}
<div className="flex items-center justify-between text-xs bg-surface-muted p-2.5 rounded-xl border border-border">
<span className="text-muted-foreground font-medium flex items-center gap-1.5">
<Building2 className="w-3.5 h-3.5 text-blue-600" />
Tenant
</span>
<span className="font-bold text-foreground bg-surface px-2 py-0.5 rounded-lg border border-border text-[11px] truncate max-w-[140px]">
{tenantName}
</span>
</div>
</div>
{/* Action Navigation */}
<div className="py-2.5 space-y-1">
<button
onClick={() => { setShowProfileMenu(false); navigate("/users"); }}
className="w-full px-3 py-2 rounded-lg text-xs font-semibold text-foreground hover:bg-surface-muted flex items-center justify-between transition-colors group cursor-pointer"
>
<span className="flex items-center gap-2">
<User className="w-3.5 h-3.5 text-muted-foreground group-hover:text-primary" />
User Directory
</span>
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground group-hover:text-primary" />
</button>
<button
onClick={() => { setShowProfileMenu(false); navigate("/settings/theme"); }}
className="w-full px-3 py-2 rounded-lg text-xs font-semibold text-foreground hover:bg-surface-muted flex items-center justify-between transition-colors group cursor-pointer"
>
<span className="flex items-center gap-2">
<Settings className="w-3.5 h-3.5 text-muted-foreground group-hover:text-primary" />
Theme Customization
</span>
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground group-hover:text-primary" />
</button>
</div>
{/* Sign Out Action */}
<div className="pt-2.5">
<button
onClick={handleLogout}
className="w-full px-3 py-2.5 rounded-xl text-xs font-bold text-red-600 hover:bg-red-50 flex items-center justify-center gap-2 border border-red-100 transition-colors shadow-2xs cursor-pointer"
>
<LogOut className="w-4 h-4" />
Sign Out of Account
</button>
</div>
</div>
)}
</div>
+5 -6
View File
@@ -46,7 +46,7 @@ export function Sidebar() {
const isChildActive = (item: NavItem): boolean => {
if (!item.children) return false;
return item.children.some(child => isItemActive(child.href) || location.pathname.startsWith(child.href));
return item.children.some(child => isItemActive(child.href));
};
const filterNavItems = (items: NavItem[]): NavItem[] => {
@@ -89,11 +89,10 @@ export function Sidebar() {
<nav className="space-y-0.5">
{filteredNavItems.map((item) => {
const Icon = item.icon;
const isActive = isItemActive(item.href) ||
(item.children && isChildActive(item));
const hasChildren = !!item.children?.length;
const isExpanded = expandedSections.includes(item.href);
const hasActiveChild = isChildActive(item);
const isActive = isItemActive(item.href) || hasActiveChild;
const isExpanded = expandedSections.includes(item.href) || hasActiveChild;
return (
<div key={item.href}>
@@ -105,7 +104,7 @@ export function Sidebar() {
navigate(item.href);
}
}}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all group text-sm font-medium ${isActive || hasActiveChild
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all group text-sm font-medium ${isActive
? "bg-primary/10 text-primary shadow-sm"
: "text-foreground/70 hover:bg-primary-ghost"
}`}
@@ -130,7 +129,7 @@ export function Sidebar() {
<div className="ml-4 mt-1 space-y-0.5">
{item.children!.map((child) => {
const ChildIcon = child.icon;
const isChildActiveItem = isItemActive(child.href) || location.pathname.startsWith(child.href);
const isChildActiveItem = isItemActive(child.href);
return (
<button
+48 -57
View File
@@ -23,13 +23,13 @@ import apiClient from "../../../api/axiosInstance";
export default function AssetList() {
const navigate = useNavigate();
const {
items,
fetchItems,
const {
items,
fetchItems,
deleteItem, replaceAsset, archiveAsset, restoreAsset } = useAsset();
const [activeTab, setActiveTab] = useState("Product Assets");
// General Modals
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
const [isDeleting, setIsDeleting] = useState(false);
@@ -161,7 +161,7 @@ export default function AssetList() {
try {
const rels = await assetsService.getRelations(asset.id);
setDrawerRelations(rels);
// Reload asset to get version history
const details = await assetsService.getById(asset.id);
if (details) {
@@ -390,7 +390,7 @@ export default function AssetList() {
items={[{ label: "Home" }, { label: "Asset Manager" }]}
actions={
<>
<Button
<Button
className="bg-primary hover:bg-primary-hover text-white flex items-center shadow-md rounded-lg px-4 py-2"
onClick={() => navigate("/assets/new")}
>
@@ -448,11 +448,10 @@ export default function AssetList() {
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`py-4 px-1 text-sm font-semibold border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${
activeTab === tab
className={`py-4 px-1 text-sm font-semibold border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${activeTab === tab
? "border-primary text-primary-dark"
: "border-transparent text-gray-500 hover:text-gray-900 hover:border-gray-300"
}`}
}`}
>
{tab === "Product Assets" && <Package className="w-4 h-4" />}
{tab === "Variant Assets" && <Grid className="w-4 h-4" />}
@@ -503,7 +502,7 @@ export default function AssetList() {
key: "preview",
label: "PREVIEW",
render: (_: any, row: any) => (
<div
<div
className="w-12 h-12 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center overflow-hidden cursor-pointer hover:opacity-85 transition-opacity"
onClick={() => openPreviewDrawer(row)}
>
@@ -520,7 +519,7 @@ export default function AssetList() {
label: "ASSET CODE",
sortable: true,
render: (val: string, row: any) => (
<span
<span
className="font-mono text-xs text-primary hover:underline cursor-pointer"
onClick={() => openPreviewDrawer(row)}
>
@@ -560,11 +559,11 @@ export default function AssetList() {
);
}
},
{
key: "file_size",
label: "FILE SIZE",
{
key: "file_size",
label: "FILE SIZE",
sortable: true,
render: (val: number) => <span className="text-sm text-gray-600 whitespace-nowrap">{formatBytes(val)}</span>
render: (val: number) => <span className="text-sm text-gray-600 whitespace-nowrap">{formatBytes(val)}</span>
},
{
key: "version",
@@ -645,7 +644,7 @@ export default function AssetList() {
<div className="flex-1 flex flex-col min-h-[400px]">
<div className="flex justify-between items-center mb-4">
<h3 className="font-bold text-gray-800">Assigned Media Files ({variantAssets.length})</h3>
<Button
<Button
className="bg-primary hover:bg-primary-hover text-white text-xs px-3 py-1.5 flex items-center"
onClick={() => setShowAssignPicker(true)}
>
@@ -678,14 +677,14 @@ export default function AssetList() {
<span className="text-[10px] font-semibold text-gray-400 font-mono">{vMapping.asset?.extension?.toUpperCase()}</span>
</div>
)}
{vMapping.is_primary && (
<div className="absolute top-2 left-2 bg-yellow-400 text-yellow-950 text-[10px] font-bold px-2 py-0.5 rounded shadow">
PRIMARY
</div>
)}
<button
<button
className="absolute top-2 right-2 bg-red-50 hover:bg-red-100 text-red-600 rounded-lg p-1.5 opacity-0 group-hover:opacity-100 transition-opacity shadow border border-red-200"
onClick={() => handleUnassignVariantAsset(vMapping.asset_id)}
title="Unlink asset"
@@ -702,7 +701,7 @@ export default function AssetList() {
<div className="text-[10px] text-gray-500 font-mono mt-0.5 truncate">
{vMapping.asset?.code}
</div>
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-gray-100">
<span className="text-[10px] font-extrabold text-primary-dark uppercase bg-primary/5 px-2 py-0.5 rounded border border-primary/10">
{vMapping.role.replace('_', ' ')}
@@ -730,12 +729,12 @@ export default function AssetList() {
{/* TAB 3: Asset Library */}
{activeTab === "Asset Library" && (
<div className="flex-1 flex flex-col lg:flex-row gap-6 min-h-[500px]">
{/* Folder Navigation Sidebar */}
<div className="w-full lg:w-64 border-r border-gray-200 pr-6 flex flex-col gap-4">
<div className="flex justify-between items-center">
<h3 className="text-sm font-bold text-gray-500">DIRECTORIES</h3>
<button
<button
onClick={() => setShowCreateFolder(!showCreateFolder)}
className="text-primary hover:text-primary-dark p-1 hover:bg-gray-100 rounded"
title="Add folder"
@@ -763,11 +762,10 @@ export default function AssetList() {
<div className="flex flex-col gap-1 overflow-y-auto max-h-[400px]">
<button
onClick={() => setSelectedFolderId(null)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-left text-xs font-semibold transition-colors ${
selectedFolderId === null
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-left text-xs font-semibold transition-colors ${selectedFolderId === null
? "bg-primary/5 text-primary-dark font-bold"
: "hover:bg-gray-50 text-gray-700"
}`}
}`}
>
<Folder className="w-4 h-4 text-gray-400" />
<span>All Library Assets</span>
@@ -777,11 +775,10 @@ export default function AssetList() {
<button
key={f.id}
onClick={() => setSelectedFolderId(f.id)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-left text-xs font-semibold transition-colors truncate ${
selectedFolderId === f.id
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-left text-xs font-semibold transition-colors truncate ${selectedFolderId === f.id
? "bg-primary/5 text-primary-dark font-bold"
: "hover:bg-gray-50 text-gray-700"
}`}
}`}
>
<Folder className="w-4 h-4 text-primary" />
<span>{f.name}</span>
@@ -794,11 +791,10 @@ export default function AssetList() {
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => setSelectedTag(null)}
className={`px-2 py-1 rounded text-[10px] font-semibold border ${
selectedTag === null
className={`px-2 py-1 rounded text-[10px] font-semibold border ${selectedTag === null
? "bg-primary text-white border-primary"
: "bg-white text-gray-600 border-gray-200 hover:bg-gray-50"
}`}
}`}
>
All Tags
</button>
@@ -806,11 +802,10 @@ export default function AssetList() {
<button
key={t.id}
onClick={() => setSelectedTag(t.name)}
className={`px-2 py-1 rounded text-[10px] font-semibold border ${
selectedTag === t.name
className={`px-2 py-1 rounded text-[10px] font-semibold border ${selectedTag === t.name
? "bg-primary text-white border-primary"
: "bg-white text-gray-600 border-gray-200 hover:bg-gray-50"
}`}
}`}
>
#{t.name}
</button>
@@ -851,14 +846,14 @@ export default function AssetList() {
{bulkSelectedIds.length > 0 && (
<div className="flex items-center gap-2 border-l border-gray-350 pl-3">
<span className="text-[10px] font-bold text-gray-500">{bulkSelectedIds.length} SELECTED:</span>
<Button
<Button
className="bg-gray-100 hover:bg-gray-200 text-gray-700 text-[10px] px-2 py-1 flex items-center"
onClick={() => handleBulkAction(statusFilter === 'inactive' ? 'restore' : 'archive')}
>
<Archive className="w-3 h-3 mr-1" />
{statusFilter === 'inactive' ? 'Restore' : 'Archive'}
</Button>
<Button
<Button
className="bg-red-50 hover:bg-red-100 text-red-600 text-[10px] px-2 py-1 flex items-center border border-red-200"
onClick={() => handleBulkAction('delete')}
>
@@ -881,11 +876,10 @@ export default function AssetList() {
{filteredAssets.map(asset => {
const isSelected = bulkSelectedIds.includes(asset.id);
return (
<div
key={asset.id}
className={`bg-white border rounded-xl overflow-hidden hover:shadow-md transition-all relative group cursor-pointer ${
isSelected ? "border-primary ring-2 ring-primary/20" : "border-gray-200"
}`}
<div
key={asset.id}
className={`bg-white border rounded-xl overflow-hidden hover:shadow-md transition-all relative group cursor-pointer ${isSelected ? "border-primary ring-2 ring-primary/20" : "border-gray-200"
}`}
>
{/* Bulk Selector Checkbox */}
<button
@@ -907,7 +901,7 @@ export default function AssetList() {
</button>
{/* Thumbnail */}
<div
<div
className="h-32 bg-gray-50 border-b border-gray-100 flex items-center justify-center overflow-hidden"
onClick={() => openPreviewDrawer(asset)}
>
@@ -973,11 +967,10 @@ export default function AssetList() {
<button
key={a.id}
onClick={() => handleSelectRelationAsset(a.id)}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-left text-xs transition-colors truncate border ${
selectedRelationAssetId === a.id
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-left text-xs transition-colors truncate border ${selectedRelationAssetId === a.id
? "bg-primary/5 border-primary/20 text-primary-dark font-semibold"
: "bg-white border-transparent hover:bg-gray-50 text-gray-700"
}`}
}`}
>
<div className="w-8 h-8 rounded bg-gray-50 border border-gray-200 flex-shrink-0 flex items-center justify-center overflow-hidden">
{a.mime_type?.startsWith('image/') ? (
@@ -1007,7 +1000,7 @@ export default function AssetList() {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Products */}
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<div className="flex items-center gap-2 mb-4">
@@ -1300,11 +1293,10 @@ export default function AssetList() {
<button
key={t.id}
onClick={() => setDrawerTab(t.id as any)}
className={`py-3 text-xs font-extrabold border-b-2 transition-colors ${
drawerTab === t.id
className={`py-3 text-xs font-extrabold border-b-2 transition-colors ${drawerTab === t.id
? "border-primary text-primary-dark"
: "border-transparent text-gray-500 hover:text-gray-800"
}`}
}`}
>
{t.label}
</button>
@@ -1384,21 +1376,20 @@ export default function AssetList() {
className="text-xs text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-xs file:font-bold file:bg-primary/10 file:text-primary hover:file:bg-primary/20"
/>
</div>
<div className="flex gap-4 pt-4 border-t border-gray-100">
<Button
className={`flex-1 text-xs border ${
selectedAsset.status === 'inactive'
<Button
className={`flex-1 text-xs border ${selectedAsset.status === 'inactive'
? "bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border-emerald-200"
: "bg-gray-50 hover:bg-gray-100 text-gray-700 border-gray-200"
}`}
}`}
onClick={handleToggleArchive}
>
<Archive className="w-3.5 h-3.5 mr-1" />
{selectedAsset.status === 'inactive' ? 'Restore Asset' : 'Archive Asset'}
</Button>
<Button
<Button
className="flex-1 bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 text-xs"
onClick={() => setDeleteModal({ isOpen: true, id: selectedAsset.id, name: selectedAsset.name })}
>
@@ -1419,7 +1410,7 @@ export default function AssetList() {
v{selectedAsset.version} (Active)
</span>
</div>
{selectedAsset.versions && selectedAsset.versions.length > 0 ? (
<div className="relative border-l border-gray-200 pl-4 ml-2 space-y-6">
{selectedAsset.versions.map((ver: any, index: number) => (
@@ -1584,7 +1575,7 @@ export default function AssetList() {
<div className="flex-1 overflow-y-auto p-5">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{items.filter(a => a.status === 'active' && a.mime_type !== 'application/x-directory' && a.file_name !== '.keep').map(a => (
<div
<div
key={a.id}
onClick={() => handleAssignAssetToVariant(a.id)}
className="border border-gray-200 rounded-xl overflow-hidden hover:border-primary/50 hover:shadow-sm cursor-pointer transition-all bg-white relative group"
+27 -28
View File
@@ -12,7 +12,7 @@ import { Save, Upload, FileText, Video, Loader2, Trash2, AlertCircle } from 'luc
import { Breadcrumb } from '../../../components/layouts/Breadcrumb';
import { notify } from '../../../services/toast';
const inputClass = (error?: boolean) =>
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-primary'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
@@ -23,7 +23,7 @@ export default function NewAsset() {
const isEdit = Boolean(id);
const { createItem, updateItem } = useAsset();
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
@@ -89,7 +89,7 @@ export default function NewAsset() {
formik.setFieldValue("file_url", data.file_url);
formik.setFieldValue("file_size", data.file_size);
formik.setFieldValue("mime_type", data.mime_type);
// Auto fill asset name with file name if not already set
if (!formik.values.name) {
formik.setFieldValue("name", file.name.replace(/\.[^/.]+$/, ""));
@@ -157,7 +157,7 @@ export default function NewAsset() {
/>
<form id="asset-form" onSubmit={formik.handleSubmit} className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Column: File Upload */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-white rounded-xl border border-primary/10 shadow-sm overflow-hidden">
@@ -170,23 +170,22 @@ export default function NewAsset() {
</div>
<div className="p-6">
{!formik.values.file_url ? (
<div
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-all flex flex-col items-center justify-center ${
dragOver ? 'border-primary bg-primary/5' : 'border-gray-200 hover:border-primary/20 hover:bg-gray-50/50 bg-white'
}`}
className={`border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-all flex flex-col items-center justify-center ${dragOver ? 'border-primary bg-primary/5' : 'border-gray-200 hover:border-primary/20 hover:bg-gray-50/50 bg-white'
}`}
>
<input
type="file"
ref={fileInputRef}
className="hidden"
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*,video/*,application/pdf,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
/>
{uploading ? (
<div className="space-y-3">
<Loader2 className="w-10 h-10 text-primary animate-spin mx-auto" />
@@ -214,10 +213,10 @@ export default function NewAsset() {
<div className="flex items-start gap-4">
<div className="w-24 h-24 rounded-lg border border-gray-200 bg-white flex items-center justify-center shrink-0 overflow-hidden shadow-sm">
{isImage ? (
<img
src={formik.values.file_url}
alt="Uploaded File"
className="w-full h-full object-cover"
<img
src={formik.values.file_url}
alt="Uploaded File"
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLElement).style.display = "none";
}}
@@ -230,11 +229,11 @@ export default function NewAsset() {
<FileText className="w-8 h-8 text-blue-500" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm text-gray-900 truncate">{formik.values.name}</div>
<div className="text-xs text-gray-400 font-mono mt-1 select-all break-all">{formik.values.file_url}</div>
<div className="flex flex-wrap gap-4 mt-3 text-xs text-gray-500">
<div>
<span className="font-medium text-gray-700">Size: </span>
@@ -246,9 +245,9 @@ export default function NewAsset() {
</div>
</div>
</div>
<button
type="button"
<button
type="button"
onClick={removeFile}
className="p-2 border border-red-100 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 hover:text-red-700 transition-colors shrink-0"
>
@@ -277,17 +276,17 @@ export default function NewAsset() {
<p className="text-xs text-primary-light mt-0.5">Define name, classification and visibility</p>
</div>
</div>
<div className="p-6 space-y-6">
<div>
<label className={labelClass}>Asset Name <span className="text-red-400">*</span></label>
<input
<input
name="name"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
value={formik.values.name}
onChange={formik.handleChange}
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Front Detail Close-up"
placeholder="e.g. Front Detail Close-up"
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
@@ -28,10 +28,12 @@ export default function AttributeList() {
const active = attributes.filter(a => a.status === 'active').length;
const variantEligible = attributes.filter((a: any) =>
a.variantEligible === true ||
a.isVariantEligible === true ||
a.is_variant_eligible === true ||
a.variantEligible === true ||
a.code === "ATTR_COLOR" ||
a.code === "ATTR_STORAGE"
).length || 2;
).length;
const mostUsed = "Brand";
@@ -98,30 +100,36 @@ export default function AttributeList() {
{
key: "isRequired",
label: "REQUIRED",
render: (val: boolean) => val ? <CheckCircle2 className="w-4 h-4 text-green-500 mx-auto" /> : <span className="text-gray-300 mx-auto"></span>,
render: (val: boolean, row: any) => (val || row.is_required) ? <CheckCircle2 className="w-4 h-4 text-green-500 mx-auto" /> : <span className="text-gray-300 mx-auto"></span>,
},
{
key: "variantEligible",
key: "isVariantEligible",
label: "VARIANT ELIGIBLE",
render: (_: any, row: any) => {
const isEligible = row.variantEligible === true ||
const isEligible = row.isVariantEligible === true ||
row.is_variant_eligible === true ||
row.variantEligible === true ||
row.code === "ATTR_COLOR" ||
row.code === "ATTR_STORAGE";
return isEligible ? <CheckCircle2 className="w-4 h-4 text-primary mx-auto" /> : <span className="text-gray-300 mx-auto"></span>;
},
},
{
key: "searchable",
key: "isSearchable",
label: "SEARCHABLE",
render: (_: any, row: any) => {
const isSearchable = row.code === "ATTR_COLOR" || row.code === "ATTR_STORAGE";
const isSearchable = row.isSearchable === true ||
row.is_searchable === true ||
row.searchable === true ||
row.code === "ATTR_COLOR" ||
row.code === "ATTR_STORAGE";
return isSearchable ? <CheckCircle2 className="w-4 h-4 text-blue-500 mx-auto" /> : <span className="text-gray-300 mx-auto"></span>;
},
},
{
key: "isLocalizable",
label: "LOCALIZABLE",
render: (val: boolean) => val ? <CheckCircle2 className="w-4 h-4 text-amber-500 mx-auto" /> : <span className="text-gray-300 mx-auto"></span>,
render: (val: boolean, row: any) => (val || row.is_localizable) ? <CheckCircle2 className="w-4 h-4 text-amber-500 mx-auto" /> : <span className="text-gray-300 mx-auto"></span>,
},
{
key: "status",
@@ -143,12 +143,12 @@ export default function NewAttribute() {
maxLength: (match as any).maxLength ?? 255,
regexPattern: (match as any).regexPattern || "",
defaultValue: (match as any).defaultValue || "",
isRequired: match.isRequired ?? false,
isVariantEligible: (match as any).isVariantEligible ?? false,
isSearchable: (match as any).isSearchable ?? false,
isFilterable: (match as any).isFilterable ?? false,
isLocalizable: match.isLocalizable ?? false,
isChannelSpecific: (match as any).isChannelSpecific ?? false,
isRequired: match.isRequired ?? (match as any).is_required ?? false,
isVariantEligible: (match as any).isVariantEligible ?? (match as any).is_variant_eligible ?? false,
isSearchable: (match as any).isSearchable ?? (match as any).is_searchable ?? false,
isFilterable: (match as any).isFilterable ?? (match as any).is_filterable ?? false,
isLocalizable: match.isLocalizable ?? (match as any).is_localizable ?? false,
isChannelSpecific: (match as any).isChannelSpecific ?? (match as any).is_channel_specific ?? false,
helpText: (match as any).helpText || "",
placeholder: (match as any).placeholder || "",
sortable: (match as any).sortable ?? false,
@@ -18,6 +18,10 @@ export interface Attribute {
isRequired: boolean;
isUnique: boolean;
isLocalizable: boolean;
isVariantEligible?: boolean;
isSearchable?: boolean;
isFilterable?: boolean;
isChannelSpecific?: boolean;
options?: string[];
status: AttributeStatus;
lastUpdated: string;
View File
@@ -1,9 +1,9 @@
import React, { useEffect, useState, useRef } from 'react';
import { assetsService, type AssetMapping } from '../../assets/services/assets.service';
import type { Asset } from '../../assets/types/assets.types';
import {
Upload, Image as ImageIcon, Video, FileText, Trash2,
Check, Loader2, Search, Info, Shield, ArrowUp, ArrowDown
import {
Upload, Image as ImageIcon, Video, FileText, Trash2,
Check, Loader2, Search, Info, Shield, ArrowUp, ArrowDown
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Loader } from '../../../components/customs/Loader';
@@ -19,13 +19,13 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
}) => {
const [assignedAssets, setAssignedAssets] = useState<AssetMapping[]>([]);
const [loading, setLoading] = useState(false);
// Library picker states
const [showPicker, setShowPicker] = useState(false);
const [libraryAssets, setLibraryAssets] = useState<Asset[]>([]);
const [loadingLibrary, setLoadingLibrary] = useState(false);
const [pickerSearch, setPickerSearch] = useState('');
// Drag & drop / upload states
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
@@ -88,7 +88,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
try {
// 1. Upload to PIM media server
const uploadedData = await assetsService.upload(file);
// 2. Create the asset registry record
const newAsset = await assetsService.create({
name: file.name.replace(/\.[^/.]+$/, ""),
@@ -235,7 +235,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
}
};
const filteredLibrary = libraryAssets.filter(asset =>
const filteredLibrary = libraryAssets.filter(asset =>
asset.name.toLowerCase().includes(pickerSearch.toLowerCase())
);
@@ -253,13 +253,12 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
const code = req.asset_type_code || req.code || '';
const isSatisfied = assignedAssets.some(a => a.asset?.assetType?.code === code || a.role === code);
return (
<span
<span
key={idx}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border ${
isSatisfied
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border ${isSatisfied
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: 'bg-amber-50 text-amber-700 border-amber-250'
}`}
}`}
>
{isSatisfied ? <Check className="w-3 h-3 text-emerald-500" /> : <span className="w-1.5 h-1.5 rounded-full bg-amber-500" />}
{req.name || code}
@@ -272,19 +271,18 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
{/* Upload Zone & Picker Trigger */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
<div
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`md:col-span-2 border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all flex flex-col items-center justify-center ${
dragOver ? 'border-primary bg-primary/5' : 'border-gray-200 hover:border-primary/20 hover:bg-gray-50/50 bg-white'
}`}
className={`md:col-span-2 border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all flex flex-col items-center justify-center ${dragOver ? 'border-primary bg-primary/5' : 'border-gray-200 hover:border-primary/20 hover:bg-gray-50/50 bg-white'
}`}
>
<input
type="file"
ref={fileInputRef}
className="hidden"
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*,video/*,application/pdf"
/>
@@ -515,11 +513,10 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
type="button"
disabled={isAssigned}
onClick={() => handleAssignFromLibrary(asset)}
className={`text-xs font-semibold px-3 py-1.5 rounded-lg border transition-all ${
isAssigned
className={`text-xs font-semibold px-3 py-1.5 rounded-lg border transition-all ${isAssigned
? 'bg-gray-50 text-gray-400 border-gray-100 cursor-not-allowed'
: 'bg-primary hover:bg-primary-hover text-white border-transparent'
}`}
}`}
>
{isAssigned ? 'Assigned' : 'Assign'}
</button>
+3 -3
View File
@@ -285,8 +285,8 @@ export default function NewProduct() {
}
}, [selectedFamily, familyBrands, family]);
const handleNext = () => {
if (step === 1 && selectedFamily) {
const handleNext = (familyId?: string) => {
if (step === 1 && (familyId || selectedFamily)) {
setStep(2);
}
};
@@ -387,7 +387,7 @@ export default function NewProduct() {
key={family.id}
family={family}
selected={selectedFamily === family.id}
onSelect={(id) => { setSelectedFamily(id); handleNext(); }}
onSelect={(id) => { setSelectedFamily(id); handleNext(id); }}
/>
))}
</div>
+22 -4
View File
@@ -11,15 +11,18 @@ import type { Role } from "../types/role.types";
import { formatDate } from "../../../utils/formatters";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { tenantService } from "../../tenants/services/tenant.service";
export default function RoleList() {
const navigate = useNavigate();
const { roles, fetchRoles, deleteRole } = useRole();
const [tenants, setTenants] = useState<any[]>([]);
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
fetchRoles();
tenantService.getAll().then(setTenants).catch(console.error);
}, [fetchRoles]);
const handleDeleteConfirm = async () => {
@@ -35,9 +38,25 @@ export default function RoleList() {
}
};
const getTenantName = (row: any) => {
if (row.tenant?.tenant_name) return row.tenant.tenant_name;
if (row.tenant_name) return row.tenant_name;
if (row.tenant_id) {
const found = tenants.find(t => String(t.id) === String(row.tenant_id));
if (found) return found.tenant_name;
}
return "Platform Core";
};
const columns = [
{ key: "role_code", label: "CODE", sortable: true },
{ key: "role_name", label: "ROLE NAME", sortable: true },
{
key: "tenant",
label: "TENANT",
sortable: true,
render: (_: any, row: any) => getTenantName(row)
},
{ key: "description", label: "DESCRIPTION", sortable: true },
{
key: "status",
@@ -78,10 +97,9 @@ export default function RoleList() {
actionConfig={{
onEdit: (row) => navigate(`/users/roles/${row.id}/edit`),
onDelete: (row) => {
if (!row.is_system_role) {
setDeleteModal({ isOpen: true, id: row.id, name: row.role_name });
}
}
setDeleteModal({ isOpen: true, id: row.id, name: row.role_name });
},
canDelete: (row) => !row.is_system_role && row.role_code !== 'SUPER_ADMIN' && row.role_code !== 'SUPERADMIN' && !row.role_name?.toLowerCase().includes('super admin')
}}
/>
</div>
@@ -291,13 +291,6 @@ export default function ThemeSettings() {
]}
/>
<div className="mb-6">
<h1 className="text-lg font-semibold text-foreground">Application Theme</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Choose a theme for the application. Changing the theme updates the application appearance.
</p>
</div>
<div className="space-y-6">
{/* Available Themes */}
<Section
@@ -8,6 +8,7 @@ export const SettingRoutes = () => (
<Route index element={<SettingList />} />
<Route path="new" element={<NewSetting />} />
<Route path=":id/edit" element={<NewSetting />} />
<Route path="theme" element={<ThemeSettings />} />
<Route path="appearance/theme" element={<ThemeSettings />} />
</Routes>
);
+136 -136
View File
@@ -102,152 +102,152 @@ export default function NewUnit() {
return (
<ProtectedRoute node="masters.units">
<PageWrapper>
<Breadcrumb
items={[{ label: 'Home' }, { label: 'Units', href: '/units' }, { label: isView ? 'View Unit' : isEdit ? 'Edit Unit' : 'Create Unit' }]}
backTo="/units"
actions={
<>
<Button variant="outline" size="md" type="button" onClick={() => navigate('/units')} disabled={formik.isSubmitting}>
{isView ? 'Back' : 'Cancel'}
</Button>
{!isView && <Button variant="outline" size="md" type="button">Save as Draft</Button>}
{!isView && <Button variant="primary" size="md" type="submit" form="unit-form" loading={formik.isSubmitting}>{isEdit ? 'Save Changes' : 'Create Unit'}</Button>}
</>
}
/>
<form id="unit-form" onSubmit={formik.handleSubmit} className="space-y-5">
<fieldset disabled={isView} className="contents">
{/* Basic Information */}
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Basic Information" subtitle="Enter the basic details for this unit" />
<div className="p-6 grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Unit Name <span className="text-red-500">*</span></label>
<Input
name="name"
value={formik.values.name}
onChange={(e) => {
formik.handleChange(e);
if (!isEdit) {
formik.setFieldValue("code", generateCodeFromName(e.target.value));
}
}}
onBlur={formik.handleBlur}
placeholder="e.g., Kilogram"
aria-invalid={formik.touched.name && Boolean(formik.errors.name)}
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<Breadcrumb
items={[{ label: 'Home' }, { label: 'Units', href: '/units' }, { label: isView ? 'View Unit' : isEdit ? 'Edit Unit' : 'Create Unit' }]}
backTo="/units"
actions={
<>
<Button variant="outline" size="md" type="button" onClick={() => navigate('/units')} disabled={formik.isSubmitting}>
{isView ? 'Back' : 'Cancel'}
</Button>
{!isView && <Button variant="outline" size="md" type="button">Save as Draft</Button>}
{!isView && <Button variant="primary" size="md" type="submit" form="unit-form" loading={formik.isSubmitting}>{isEdit ? 'Save Changes' : 'Create Unit'}</Button>}
</>
}
/>
<div>
<label className={labelClass}>Internal Code <span className="text-red-500">*</span></label>
<Input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g., kg"
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
/>
</div>
<div>
<label className={labelClass}>Symbol <span className="text-red-500">*</span></label>
<Input
name="symbol"
value={formik.values.symbol}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g., kg"
aria-invalid={formik.touched.symbol && Boolean(formik.errors.symbol)}
/>
</div>
<div>
<label className={labelClass}>Unit Type <span className="text-red-500">*</span></label>
<Select
name="unitType"
value={formik.values.unitType}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.unitType && Boolean(formik.errors.unitType)}
placeholder="Select Type"
>
{UNIT_TYPES.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</Select>
</div>
<div className="col-span-2">
<label className={labelClass}>Description</label>
<TextArea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
placeholder="Describe the purpose of this unit..."
rows={4}
style={{ minHeight: '100px' }}
/>
<p className="text-xs text-gray-400 mt-1">Optional description to help users understand when to use this unit</p>
</div>
<div className="col-span-2">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="w-5 h-5 rounded border-gray-300 text-primary focus:ring-primary accent-primary"
<form id="unit-form" onSubmit={formik.handleSubmit} className="space-y-5">
<fieldset disabled={isView} className="contents">
{/* Basic Information */}
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Basic Information" subtitle="Enter the basic details for this unit" />
<div className="p-6 grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Unit Name <span className="text-red-500">*</span></label>
<Input
name="name"
value={formik.values.name}
onChange={(e) => {
formik.handleChange(e);
if (!isEdit) {
formik.setFieldValue("code", generateCodeFromName(e.target.value));
}
}}
onBlur={formik.handleBlur}
placeholder="e.g., Kilogram"
aria-invalid={formik.touched.name && Boolean(formik.errors.name)}
/>
<span className="text-sm font-medium text-gray-700">
Active <span className="text-gray-500 font-normal">(Unchecked units will be saved as inactive)</span>
</span>
</label>
</div>
</div>
</div>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
{/* Conversion Configuration */}
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Conversion Configuration" subtitle="Define conversion factors for this unit (optional)" />
<div className="p-6 space-y-5">
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Conversion Factor</label>
<Input
name="conversionFactor"
type="number"
step="0.000001"
value={formik.values.conversionFactor}
onChange={formik.handleChange}
placeholder="e.g., 0.001"
/>
<p className="text-xs text-gray-500 mt-1">How many base units equal one of this unit</p>
</div>
<div>
<label className={labelClass}>Internal Code <span className="text-red-500">*</span></label>
<Input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g., kg"
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
/>
</div>
<div>
<label className={labelClass}>Base Unit</label>
<Input
name="baseUnit"
value={formik.values.baseUnit}
onChange={formik.handleChange}
placeholder="e.g., kg"
/>
<p className="text-xs text-gray-500 mt-1">The reference unit for conversion</p>
<div>
<label className={labelClass}>Symbol <span className="text-red-500">*</span></label>
<Input
name="symbol"
value={formik.values.symbol}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g., kg"
aria-invalid={formik.touched.symbol && Boolean(formik.errors.symbol)}
/>
</div>
<div>
<label className={labelClass}>Unit Type <span className="text-red-500">*</span></label>
<Select
name="unitType"
value={formik.values.unitType}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.unitType && Boolean(formik.errors.unitType)}
placeholder="Select Type"
>
{UNIT_TYPES.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</Select>
</div>
<div className="col-span-2">
<label className={labelClass}>Description</label>
<TextArea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
placeholder="Describe the purpose of this unit..."
rows={4}
style={{ minHeight: '100px' }}
/>
<p className="text-xs text-gray-400 mt-1">Optional description to help users understand when to use this unit</p>
</div>
<div className="col-span-2">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="w-5 h-5 rounded border-gray-300 text-primary focus:ring-primary accent-primary"
/>
<span className="text-sm font-medium text-gray-700">
Active <span className="text-gray-500 font-normal">(Unchecked units will be saved as inactive)</span>
</span>
</label>
</div>
</div>
</div>
<div className="mt-4 p-4 bg-blue-50 border border-blue-100 rounded-lg text-sm text-blue-700">
<strong className="text-blue-900">Note:</strong> Conversion factor represents how many base units equal one of this unit.
For example, 1 gram = 0.001 kilograms.
{/* Conversion Configuration */}
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Conversion Configuration" subtitle="Define conversion factors for this unit (optional)" />
<div className="p-6 space-y-5">
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Conversion Factor</label>
<Input
name="conversionFactor"
type="number"
step="0.000001"
value={formik.values.conversionFactor}
onChange={formik.handleChange}
placeholder="e.g., 0.001"
/>
<p className="text-xs text-gray-500 mt-1">How many base units equal one of this unit</p>
</div>
<div>
<label className={labelClass}>Base Unit</label>
<Input
name="baseUnit"
value={formik.values.baseUnit}
onChange={formik.handleChange}
placeholder="e.g., kg"
/>
<p className="text-xs text-gray-500 mt-1">The reference unit for conversion</p>
</div>
</div>
<div className="mt-4 p-4 bg-blue-50 border border-blue-100 rounded-lg text-sm text-blue-700">
<strong className="text-blue-900">Note:</strong> Conversion factor represents how many base units equal one of this unit.
For example, 1 gram = 0.001 kilograms.
</div>
</div>
</div>
</div>
</fieldset>
</form>
</PageWrapper>
</form>
</PageWrapper>
</ProtectedRoute>
);
}
+167 -41
View File
@@ -1,17 +1,18 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useFormik } from "formik";
import * as Yup from "yup";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { Input } from "../../../components/customs/Input";
import { Select } from "../../../components/customs/Select";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { usersService } from "../services/users.service";
import { rolesService } from "../services/roles.service";
import { tenantService } from "../../tenants/services/tenant.service";
import type { DBRole } from "../services/roles.service";
import type { Tenant } from "../../tenants/types/tenant.types";
import { Save, AlertCircle, CheckCircle2 } from "lucide-react";
import { Save, AlertCircle, CheckCircle2, Edit2, ArrowLeft } from "lucide-react";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
function CardHeader({ title, subtitle }: { title: string; subtitle?: string }) {
@@ -31,11 +32,15 @@ const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
export default function NewUser() {
const navigate = useNavigate();
const location = useLocation();
const { id } = useParams<{ id: string }>();
const isEdit = Boolean(id);
const isEdit = Boolean(id) && location.pathname.endsWith("/edit");
const isView = Boolean(id) && !isEdit;
const [roles, setRoles] = useState<DBRole[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [userDetails, setUserDetails] = useState<any | null>(null);
const [rolesLoading, setRolesLoading] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitSuccess, setSubmitSuccess] = useState<string | null>(null);
@@ -54,10 +59,10 @@ export default function NewUser() {
status: "active" as "active" | "inactive",
},
validationSchema: Yup.object().shape({
email: isEdit
email: (isEdit || isView)
? Yup.string().optional()
: Yup.string().email("Invalid email address").required("Email is required"),
roleId: isEdit
roleId: (isEdit || isView)
? Yup.string().optional()
: Yup.string().required("Please assign a role to this user"),
tenantId: Yup.string().optional(),
@@ -79,11 +84,11 @@ export default function NewUser() {
});
setSubmitSuccess("User updated successfully!");
setTimeout(() => navigate("/users"), 1200);
} else {
} else if (!isView) {
await usersService.invite(
values.email,
values.name,
(values.tenantId === "all" || !values.tenantId) ? null : values.tenantId,
(values.tenantId === "platform" || !values.tenantId) ? null : values.tenantId,
[Number(values.roleId)]
);
setSubmitSuccess("Invitation sent successfully!");
@@ -102,8 +107,6 @@ export default function NewUser() {
});
useEffect(() => {
if (isEdit) return;
formik.setFieldValue("roleId", "");
setRolesLoading(true);
const tenantId = formik.values.tenantId || null;
rolesService
@@ -112,15 +115,18 @@ export default function NewUser() {
.catch(console.error)
.finally(() => setRolesLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formik.values.tenantId, isEdit]);
}, [formik.values.tenantId]);
useEffect(() => {
if (isEdit && id) {
if ((isEdit || isView) && id) {
usersService.getById(id).then((item) => {
if (item) {
setUserDetails(item);
const itemAny = item as any;
const currentRoleId = Array.isArray(itemAny.roles) && itemAny.roles.length > 0 ? String(itemAny.roles[0].id) : "";
formik.setValues({
email: item.email,
roleId: "",
roleId: currentRoleId,
tenantId: item.tenant_id ? String(item.tenant_id) : "",
name: item.user_name || "",
phone: item.phone || "",
@@ -130,7 +136,21 @@ export default function NewUser() {
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id]);
}, [isEdit, isView, id]);
const selTenantObj = tenants.find(t => String(t.id) === String(formik.values.tenantId));
const tenantName = selTenantObj
? selTenantObj.tenant_name
: formik.values.tenantId === "platform" || !formik.values.tenantId
? "Platform Core (No Tenant)"
: `Tenant #${formik.values.tenantId}`;
const userRoles = userDetails?.roles || [];
const roleName = userRoles.length > 0
? userRoles.map((r: any) => r.role_name).join(", ")
: (userDetails?.email === 'admin@admin.com' || userDetails?.user_type === 'platform')
? "Super Admin"
: "No Role Assigned";
return (
<PageWrapper>
@@ -138,18 +158,29 @@ export default function NewUser() {
items={[
{ label: "Home" },
{ label: "Users", href: "/users" },
{ label: isEdit ? "Edit User" : "Invite User" },
{ label: isView ? "User Details" : isEdit ? "Edit User" : "Invite User" },
]}
backTo="/users"
actions={
<>
<Button variant="outline" size="md" type="button" onClick={() => navigate("/users")} disabled={formik.isSubmitting}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit" form="user-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
{isEdit ? "Update User" : "Send Invitation"}
</Button>
</>
isView ? (
<>
<Button variant="outline" size="md" type="button" onClick={() => navigate("/users")} icon={<ArrowLeft className="w-4 h-4" />}>
Back to Users
</Button>
<Button variant="primary" size="md" type="button" onClick={() => navigate(`/users/${id}/edit`)} icon={<Edit2 className="w-4 h-4" />}>
Edit User
</Button>
</>
) : (
<>
<Button variant="outline" size="md" type="button" onClick={() => navigate("/users")} disabled={formik.isSubmitting}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit" form="user-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
{isEdit ? "Update User" : "Send Invitation"}
</Button>
</>
)
}
/>
@@ -169,13 +200,96 @@ export default function NewUser() {
<form id="user-form" onSubmit={formik.handleSubmit}>
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader
title={isEdit ? "User Information" : "Invite Member"}
subtitle={isEdit ? "Update user details and status" : "Fill in details to send an invitation"}
title={isView ? "User Details & Profile" : isEdit ? "User Information" : "Invite Member"}
subtitle={isView ? "Complete member details and access role configuration" : isEdit ? "Update user details and status" : "Fill in details to send an invitation"}
/>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{!isEdit ? (
{isView ? (
<>
{/* VIEW MODE: Shows comprehensive data in exact same edit screen UI */}
<div className="md:col-span-2">
<label className={labelClass}>User Name</label>
<Input
readOnly
value={formik.values.name || userDetails?.user_name || "Team Member"}
className="bg-gray-50 border-gray-200 font-semibold text-gray-900"
/>
</div>
<div className="md:col-span-1">
<label className={labelClass}>Email Address</label>
<Input
readOnly
value={formik.values.email || userDetails?.email || "—"}
className="bg-gray-50 border-gray-200 text-gray-900"
/>
</div>
<div className="md:col-span-1">
<label className={labelClass}>Phone Number</label>
<Input
readOnly
value={formik.values.phone || userDetails?.phone || "Not Provided"}
className="bg-gray-50 border-gray-200 text-gray-900"
/>
</div>
<div className="md:col-span-1">
<label className={labelClass}>Tenant Organization</label>
<Input
readOnly
value={tenantName}
className="bg-gray-50 border-gray-200 font-semibold text-gray-900"
/>
</div>
<div className="md:col-span-1">
<label className={labelClass}>Assigned Role</label>
<Input
readOnly
value={roleName}
className="bg-gray-50 border-gray-200 font-bold text-primary-dark"
/>
</div>
<div className="md:col-span-1">
<label className={labelClass}>Account Status</label>
<div className="h-10 px-3 py-2 border border-gray-200 rounded-lg bg-gray-50 flex items-center">
<StatusBadge status={formik.values.status === 'active' || userDetails?.status === 'active' || userDetails?.status === true ? "active" : "disabled"} />
</div>
</div>
<div className="md:col-span-1">
<label className={labelClass}>Access Scope / User Type</label>
<Input
readOnly
value={userDetails?.user_type === 'platform' ? 'Platform Core Admin' : 'Tenant Member'}
className="bg-gray-50 border-gray-200 text-gray-900"
/>
</div>
<div className="md:col-span-1">
<label className={labelClass}>Joined Date</label>
<Input
readOnly
value={userDetails?.createdAt ? new Date(userDetails.createdAt).toLocaleString() : "—"}
className="bg-gray-50 border-gray-200 text-gray-900"
/>
</div>
<div className="md:col-span-1">
<label className={labelClass}>Last Updated</label>
<Input
readOnly
value={userDetails?.updatedAt ? new Date(userDetails.updatedAt).toLocaleString() : "—"}
className="bg-gray-50 border-gray-200 text-gray-900"
/>
</div>
</>
) : !isEdit ? (
<>
{/* CREATE / INVITE MODE */}
<div className="md:col-span-2">
<label className={labelClass}>User Name *</label>
<Input
@@ -208,16 +322,16 @@ export default function NewUser() {
</div>
<div className="md:col-span-1">
<label className={labelClass}>Select Tenant</label>
<label className={labelClass}>Select Tenant *</label>
<Select
name="tenantId"
value={formik.values.tenantId}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="All Tenants (Shows All Roles)"
placeholder="Select a Tenant..."
>
<option value="all">All Tenants (Shows All Roles)</option>
<option value="">No Tenant (Platform Admin)</option>
<option value="">Select a Tenant...</option>
<option value="platform">Platform Core (No Tenant)</option>
{tenants.map((t) => (
<option key={t.id} value={t.id}>{t.tenant_name}</option>
))}
@@ -228,16 +342,27 @@ export default function NewUser() {
</div>
<div className="md:col-span-2">
<label className={labelClass}>
Assign Role *
{formik.values.tenantId === "all" ? (
<span className="ml-2 text-xs font-normal text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full">Showing all roles</span>
) : formik.values.tenantId ? (
<span className="ml-2 text-xs font-normal text-primary bg-primary/5 px-2 py-0.5 rounded-full">Showing roles for selected tenant</span>
) : (
<span className="ml-2 text-xs font-normal text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">Showing platform roles</span>
)}
</label>
{(() => {
const selTenant = tenants.find(t => String(t.id) === String(formik.values.tenantId));
return (
<label className={labelClass}>
Assign Role *
{selTenant ? (
<span className="ml-2 text-xs font-normal text-primary bg-primary/5 px-2.5 py-0.5 rounded-full font-medium">
Showing roles for {selTenant.tenant_name}
</span>
) : formik.values.tenantId === "platform" ? (
<span className="ml-2 text-xs font-normal text-gray-700 bg-gray-100 px-2.5 py-0.5 rounded-full font-medium">
Showing platform roles
</span>
) : (
<span className="ml-2 text-xs font-normal text-amber-700 bg-amber-50 px-2.5 py-0.5 rounded-full font-medium">
Select a tenant to view its roles
</span>
)}
</label>
);
})()}
<Select
name="roleId"
value={formik.values.roleId}
@@ -250,8 +375,8 @@ export default function NewUser() {
<option key={r.id} value={r.id}>{r.role_name}</option>
))}
</Select>
{roles.length === 0 && !rolesLoading && (
<p className="text-xs text-amber-600 mt-1">No roles found for this selection. Please create a role first.</p>
{roles.length === 0 && !rolesLoading && formik.values.tenantId && (
<p className="text-xs text-amber-600 mt-1">No roles found for this tenant. Please create a role for this tenant first.</p>
)}
{formik.touched.roleId && formik.errors.roleId && (
<p className={errorClass}><AlertCircle className="w-3 h-3" />{formik.errors.roleId}</p>
@@ -260,6 +385,7 @@ export default function NewUser() {
</>
) : (
<>
{/* EDIT MODE */}
<div className="md:col-span-2">
<label className={labelClass}>User Name *</label>
<Input
+379 -72
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { Plus, Edit2, Shield, Users, Lock, Check } from "lucide-react";
import { Plus, Edit2, Shield, Users, Lock, Check, Trash2, ShieldAlert } from "lucide-react";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
@@ -11,23 +11,59 @@ import { StatsCard } from "../../../components/customs/StatsCard";
import { usersService } from "../services/users.service";
import { rolesService } from "../services/roles.service";
import { tenantService } from "../../tenants/services/tenant.service";
import { notify } from "../../../services/toast";
import type { DBRole, PermissionNode } from "../services/roles.service";
import type { Tenant } from "../../tenants/types/tenant.types";
export default function UserList() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState("Team Members");
const [users, setUsers] = useState<any[]>([]);
const [roles, setRoles] = useState<DBRole[]>([]);
const [permissionNodes, setPermissionNodes] = useState<PermissionNode[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null);
const [isEditingRole, setIsEditingRole] = useState(false);
const [editedRole, setEditedRole] = useState<DBRole | null>(null);
const [selectedTenantFilter, setSelectedTenantFilter] = useState<string>("all");
const filteredRoles = useMemo(() => {
if (selectedTenantFilter === "all") return roles;
if (selectedTenantFilter === "platform") return roles.filter(r => !r.tenant_id);
return roles.filter(r => String(r.tenant_id) === selectedTenantFilter || !r.tenant_id);
}, [roles, selectedTenantFilter]);
// Delete User Modal State
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
const [isDeletingUser, setIsDeletingUser] = useState(false);
// Delete Role Modal State
const [deleteRoleModal, setDeleteRoleModal] = useState<{ isOpen: boolean; id: number | null; name: string }>({ isOpen: false, id: null, name: "" });
const [isDeletingRole, setIsDeletingRole] = useState(false);
// View User Modal State
const [viewUserModal, setViewUserModal] = useState<{ isOpen: boolean; user: any | null }>({ isOpen: false, user: null });
// Edit User Modal State
const [editUserModal, setEditUserModal] = useState<{
isOpen: boolean;
user: any | null;
name: string;
email: string;
tenantId: string;
roleIds: number[];
status: boolean;
}>({
isOpen: false,
user: null,
name: "",
email: "",
tenantId: "",
roleIds: [],
status: true
});
// Invite Modal State
const [isInviteOpen, setIsInviteOpen] = useState(false);
@@ -42,20 +78,41 @@ export default function UserList() {
const [newRoleDesc, setNewRoleDesc] = useState("");
const [newRoleStatus, setNewRoleStatus] = useState(true);
const isSuperAdminUser = (user: any) => {
if (!user) return false;
if (user.email === 'admin@admin.com') return true;
const userRoles = user.roles || [];
return userRoles.some((r: any) =>
r.role_code === 'SUPER_ADMIN' ||
r.role_code === 'SUPERADMIN' ||
r.role_name?.toLowerCase().includes('super admin') ||
r.is_system_role
);
};
const isSuperAdminRole = (role: any) => {
if (!role) return false;
return Boolean(
role.is_system_role ||
role.role_code === 'SUPER_ADMIN' ||
role.role_code === 'SUPERADMIN' ||
role.role_name?.toLowerCase().includes('super admin')
);
};
const loadData = async () => {
try {
const u = await usersService.getAll();
const r = await rolesService.getAll();
const p = await rolesService.getPermissions();
const t = await tenantService.getAll();
setUsers(u);
setRoles(r);
setPermissionNodes(p);
setTenants(t);
if (r.length > 0) {
// Maintain selection or select first
const currentSelected = r.find(role => role.id === selectedRoleId);
if (currentSelected) {
setEditedRole(currentSelected);
@@ -84,7 +141,6 @@ export default function UserList() {
const handleTogglePermission = (nodeId: number, action: string) => {
if (!editedRole) return;
setEditedRole(prev => {
if (!prev) return null;
const permissions = [...(prev.permissions || [])];
@@ -97,7 +153,6 @@ export default function UserList() {
rolePermission[key] = !rolePermission[key];
permissions[index] = { ...target, RolePermission: rolePermission };
} else {
// Node not yet assigned, add it
const originalNode = permissionNodes.find(n => n.id === nodeId);
if (originalNode) {
permissions.push({
@@ -138,34 +193,83 @@ export default function UserList() {
const updated = await rolesService.update(editedRole.id, payload);
setRoles(prev => prev.map(r => r.id === editedRole.id ? updated : r));
setIsEditingRole(false);
notify.success("Role permissions updated successfully!");
await loadData();
} catch (e) {
console.error("Failed to save role changes:", e);
} catch (e: any) {
notify.error(e);
}
};
const handleDeleteUserConfirm = async () => {
if (!deleteModal.id) return;
setIsDeletingUser(true);
try {
await usersService.delete(deleteModal.id);
notify.success("Team member deleted successfully!");
setDeleteModal({ isOpen: false, id: "", name: "" });
await loadData();
} catch (e: any) {
notify.error(e);
} finally {
setIsDeletingUser(false);
}
};
const handleDeleteRoleConfirm = async () => {
if (!deleteRoleModal.id) return;
setIsDeletingRole(true);
try {
await rolesService.delete(deleteRoleModal.id);
notify.success("Role deleted successfully!");
setDeleteRoleModal({ isOpen: false, id: null, name: "" });
await loadData();
} catch (e: any) {
notify.error(e);
} finally {
setIsDeletingRole(false);
}
};
const handleEditUserSubmit = async () => {
if (!editUserModal.user) return;
try {
await usersService.update(editUserModal.user.id, {
user_name: editUserModal.name,
email: editUserModal.email,
tenant_id: editUserModal.tenantId || null,
role_ids: editUserModal.roleIds,
status: editUserModal.status ? 'active' : 'inactive'
} as any);
notify.success("Member updated successfully!");
setEditUserModal({ isOpen: false, user: null, name: "", email: "", tenantId: "", roleIds: [], status: true });
await loadData();
} catch (e: any) {
notify.error(e);
}
};
const handleInviteSubmit = async () => {
if (!inviteEmail || !inviteRoleId || !inviteName) {
alert("Please enter name, email and select a role");
notify.error("Please enter name, email and select a role");
return;
}
try {
await usersService.invite(inviteEmail, inviteName, inviteTenantId || null, [Number(inviteRoleId)]);
notify.success("Invitation sent successfully!");
setIsInviteOpen(false);
setInviteName("");
setInviteEmail("");
setInviteTenantId("");
setInviteRoleId("");
await loadData();
} catch (e) {
console.error("Invitation failed:", e);
alert("Failed to send invitation");
} catch (e: any) {
notify.error(e);
}
};
const handleCreateRoleSubmit = async () => {
if (!newRoleName) {
alert("Please enter a role name");
notify.error("Please enter a role name");
return;
}
try {
@@ -175,14 +279,14 @@ export default function UserList() {
status: newRoleStatus,
permissions: []
});
notify.success("Role created successfully!");
setIsCreateRoleOpen(false);
setNewRoleName("");
setNewRoleDesc("");
setNewRoleStatus(true);
await loadData();
} catch (e) {
console.error("Failed to create role:", e);
alert("Failed to create role");
} catch (e: any) {
notify.error(e);
}
};
@@ -195,11 +299,18 @@ export default function UserList() {
const initials = name.slice(0, 2).toUpperCase();
return (
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-medium bg-primary`}>
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-medium bg-primary shrink-0`}>
{initials}
</div>
<div>
<div className="font-medium text-gray-900">{name}</div>
<div className="font-medium text-gray-900 flex items-center gap-1.5">
{name}
{isSuperAdminUser(row) && (
<span className="px-1.5 py-0.5 text-[10px] font-bold text-amber-700 bg-amber-50 border border-amber-200 rounded">
SUPER ADMIN
</span>
)}
</div>
<div className="text-xs text-gray-500">{row.email}</div>
</div>
</div>
@@ -242,7 +353,7 @@ export default function UserList() {
{
key: "status",
label: "STATUS",
render: (val: any) => <StatusBadge status={val ? "active" : "disabled"} />
render: (val: any) => <StatusBadge status={val === 'active' || val === true ? "active" : "disabled"} />
},
{
key: "twoFa",
@@ -262,12 +373,10 @@ export default function UserList() {
{
key: "joined",
label: "JOINED",
render: (_: any, row: any) => <span className="text-sm text-gray-600">{new Date(row.createdAt).toLocaleDateString()}</span>
render: (_: any, row: any) => <span className="text-sm text-gray-600">{row.createdAt ? new Date(row.createdAt).toLocaleDateString() : "—"}</span>
}
];
return (
<PageWrapper>
<Breadcrumb
@@ -285,7 +394,7 @@ export default function UserList() {
<StatsCard
title="Total Members"
value={users.length}
subtitle={`${users.filter(u => u.status).length} active`}
subtitle={`${users.filter(u => u.status === 'active' || u.status === true).length} active`}
color="slate"
icon={<Users className="w-5 h-5" />}
/>
@@ -305,7 +414,7 @@ export default function UserList() {
/>
<StatsCard
title="Pending Invites"
value={users.filter(u => !u.status).length}
value={users.filter(u => u.status === 'inactive' || u.status === false).length}
subtitle="awaiting signup"
color="orange"
icon={<Plus className="w-5 h-5" />}
@@ -314,20 +423,18 @@ export default function UserList() {
{/* Tabs */}
<div className="flex gap-2 mb-6">
<button
<button
onClick={() => { setActiveTab("Team Members"); setIsEditingRole(false); }}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
activeTab === "Team Members" ? "border-primary/20 text-primary-dark bg-white shadow-sm" : "border-transparent text-gray-500 hover:text-gray-900"
}`}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${activeTab === "Team Members" ? "border-primary/20 text-primary-dark bg-white shadow-sm" : "border-transparent text-gray-500 hover:text-gray-900"
}`}
>
<Users className="w-4 h-4" />
Team Members
</button>
<button
<button
onClick={() => setActiveTab("Roles & Permissions")}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
activeTab === "Roles & Permissions" ? "border-primary/20 text-primary-dark bg-white shadow-sm" : "border-transparent text-gray-500 hover:text-gray-900"
}`}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${activeTab === "Roles & Permissions" ? "border-primary/20 text-primary-dark bg-white shadow-sm" : "border-transparent text-gray-500 hover:text-gray-900"
}`}
>
<Shield className="w-4 h-4" />
Roles & Permissions
@@ -337,14 +444,16 @@ export default function UserList() {
{/* Main Content Area */}
{activeTab === "Team Members" && (
<div className="mb-8">
<DataTable
columns={columns}
data={users}
<DataTable
columns={columns}
data={users}
actionConfig={{
onView: () => {},
onEdit: () => {},
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.user_name || row.email })
onView: (row) => navigate(`/users/${row.id}`),
onEdit: (row) => navigate(`/users/${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.user_name || row.email }),
canDelete: (row) => !isSuperAdminUser(row)
}}
onRowClick={(row) => navigate(`/users/${row.id}`)}
searchPlaceholder="Search members..."
/>
</div>
@@ -354,28 +463,47 @@ export default function UserList() {
<div className="flex flex-col lg:flex-row gap-6">
{/* Left Sidebar - Roles List */}
<div className="w-full lg:w-1/3 flex flex-col gap-4">
{roles.map((role) => (
<div
<div className="bg-white p-3 rounded-xl border border-gray-200 shadow-2xs">
<label className="block text-xs font-semibold text-gray-500 uppercase mb-1.5">Filter Roles by Tenant</label>
<select
value={selectedTenantFilter}
onChange={(e) => setSelectedTenantFilter(e.target.value)}
className="w-full h-9 px-3 py-1.5 border border-gray-200 rounded-lg text-xs font-medium focus:outline-none focus:ring-2 focus:ring-primary bg-white text-gray-900"
>
<option value="all">All Tenants & Platform</option>
<option value="platform">Platform Core Roles</option>
{tenants.map(t => (
<option key={t.id} value={String(t.id)}>{t.tenant_name}</option>
))}
</select>
</div>
{filteredRoles.map((role) => (
<div
key={role.id}
onClick={() => { handleRoleSelect(role.id); setIsEditingRole(false); }}
className={`p-4 rounded-xl border cursor-pointer transition-all ${
selectedRoleId === role.id
? "border-primary/30 shadow-sm bg-white ring-1 ring-primary/10"
onClick={() => { handleRoleSelect(role.id); setIsEditingRole(false); }}
className={`p-4 rounded-xl border cursor-pointer transition-all ${selectedRoleId === role.id
? "border-primary/30 shadow-sm bg-white ring-1 ring-primary/10"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
}`}
>
<div className="flex justify-between items-start mb-2">
<div className="flex items-center gap-2">
<div className={`w-2.5 h-2.5 rounded-full bg-primary`}></div>
<span className="font-semibold text-gray-900">{role.role_name}</span>
{isSuperAdminRole(role) && (
<span className="px-1.5 py-0.5 text-[9px] font-bold text-amber-700 bg-amber-50 border border-amber-200 rounded uppercase">
System
</span>
)}
</div>
<span className="text-xs text-gray-500">{role.members || 0} members</span>
</div>
<p className="text-sm text-gray-500 line-clamp-2">{role.description || "No description provided."}</p>
</div>
))}
<button
<button
onClick={() => setIsCreateRoleOpen(true)}
className="p-4 rounded-xl border border-dashed border-gray-300 text-gray-500 hover:text-gray-900 hover:border-gray-400 hover:bg-gray-50 transition-colors flex items-center justify-center gap-2 text-sm font-medium"
>
@@ -394,22 +522,36 @@ export default function UserList() {
<div className="w-3.5 h-3.5 rounded-full bg-primary"></div>
<h2 className="text-xl font-bold text-gray-900">{selectedRole.role_name}</h2>
<span className="text-sm text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">{selectedRole.members || 0} members</span>
{isSuperAdminRole(selectedRole) && (
<span className="flex items-center gap-1 text-xs font-semibold text-amber-700 bg-amber-50 border border-amber-200 px-2.5 py-0.5 rounded-full">
<ShieldAlert className="w-3.5 h-3.5" /> System Protected
</span>
)}
</div>
<p className="text-sm text-gray-600">{selectedRole.description || "No description provided."}</p>
</div>
{!isEditingRole ? (
<Button variant="outline" className="text-gray-700" onClick={() => { setIsEditingRole(true); setEditedRole(selectedRole); }}>
<Edit2 className="w-4 h-4 mr-2" />
Edit Role
</Button>
{isSuperAdminRole(selectedRole) ? (
<span className="text-xs text-gray-400 italic">Core role permissions cannot be modified or deleted.</span>
) : !isEditingRole ? (
<div className="flex gap-2">
<Button variant="outline" className="text-gray-700" onClick={() => { setIsEditingRole(true); setEditedRole(selectedRole); }}>
<Edit2 className="w-4 h-4 mr-2" />
Edit Role
</Button>
<Button variant="outline" className="text-red-600 border-red-200 hover:bg-red-50" onClick={() => setDeleteRoleModal({ isOpen: true, id: selectedRole.id, name: selectedRole.role_name })}>
<Trash2 className="w-4 h-4 mr-2" />
Delete Role
</Button>
</div>
) : (
<div className="flex gap-2">
<Button variant="outline" className="text-gray-600 hover:bg-gray-50" onClick={() => { setIsEditingRole(false); setEditedRole(selectedRole); }}>
Cancel
</Button>
<Button className="bg-primary hover:bg-primary-hover text-white" onClick={handleSaveRole}>
Save Changes
</Button>
<Button variant="outline" className="text-gray-600 hover:bg-gray-50" onClick={() => { setIsEditingRole(false); setEditedRole(selectedRole); }}>
Cancel
</Button>
<Button className="bg-primary hover:bg-primary-hover text-white" onClick={handleSaveRole}>
Save Changes
</Button>
</div>
)}
</div>
@@ -442,15 +584,15 @@ export default function UserList() {
<tr key={node.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-3 font-medium text-gray-900">{node.node_name}</td>
{actions.map((act) => {
const isAllowed = rp[`can_${act}` as keyof typeof rp] === true;
const isAllowed = isSuperAdminRole(selectedRole) ? true : (rp[`can_${act}` as keyof typeof rp] === true);
return (
<td key={act} className="py-3 text-center">
{isEditingRole ? (
<input
type="checkbox"
checked={isAllowed}
{isEditingRole && !isSuperAdminRole(selectedRole) ? (
<input
type="checkbox"
checked={isAllowed}
onChange={() => handleTogglePermission(node.id, act)}
className="w-4 h-4 text-primary rounded border-gray-300 focus:ring-primary"
className="w-4 h-4 text-primary rounded border-gray-300 focus:ring-primary"
/>
) : (
isAllowed ? (
@@ -473,15 +615,180 @@ export default function UserList() {
</div>
)}
{/* Delete User Confirmation Modal */}
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Remove Member"
description="Are you sure you want to remove this team member? This action cannot be undone."
itemName={deleteModal.name}
onConfirm={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
loading={isDeletingUser}
onConfirm={handleDeleteUserConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
{/* Delete Role Confirmation Modal */}
<ConfirmationModal
isOpen={deleteRoleModal.isOpen}
title="Delete Role"
description="Are you sure you want to delete this role? Any members assigned to this role will lose its permissions."
itemName={deleteRoleModal.name}
loading={isDeletingRole}
onConfirm={handleDeleteRoleConfirm}
onCancel={() => setDeleteRoleModal({ isOpen: false, id: null, name: "" })}
/>
{/* View User Details Modal */}
{viewUserModal.isOpen && viewUserModal.user && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl max-w-lg w-full p-6 shadow-xl border border-gray-100">
<div className="flex justify-between items-start mb-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-primary text-white text-lg font-bold flex items-center justify-center">
{(viewUserModal.user.user_name || viewUserModal.user.email).slice(0, 2).toUpperCase()}
</div>
<div>
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2">
{viewUserModal.user.user_name || "Team Member"}
{isSuperAdminUser(viewUserModal.user) && (
<span className="px-2 py-0.5 text-[10px] font-bold text-amber-700 bg-amber-50 border border-amber-200 rounded">
SUPER ADMIN
</span>
)}
</h3>
<p className="text-sm text-gray-500">{viewUserModal.user.email}</p>
</div>
</div>
<button
onClick={() => setViewUserModal({ isOpen: false, user: null })}
className="text-gray-400 hover:text-gray-600 text-lg font-bold"
>
</button>
</div>
<div className="space-y-4 text-sm border-t border-b border-gray-100 py-4 mb-6">
<div className="flex justify-between items-center">
<span className="text-gray-500 font-medium">Status</span>
<StatusBadge status={viewUserModal.user.status === 'active' || viewUserModal.user.status === true ? "active" : "disabled"} />
</div>
<div className="flex justify-between items-center">
<span className="text-gray-500 font-medium">Tenant</span>
<span className="font-semibold text-gray-800">
{tenants.find(t => t.id === viewUserModal.user.tenant_id)?.tenant_name || "Platform Core"}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-500 font-medium">Assigned Roles</span>
<div className="flex flex-wrap gap-1 justify-end">
{viewUserModal.user.roles && viewUserModal.user.roles.length > 0 ? (
viewUserModal.user.roles.map((r: any) => (
<span key={r.id} className="px-2 py-0.5 text-xs font-medium text-white rounded bg-primary">
{r.role_name}
</span>
))
) : (
<span className="text-xs text-gray-400">No Role Assigned</span>
)}
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-500 font-medium">2FA Protection</span>
<span className="text-xs text-gray-500 flex items-center gap-1"><Lock className="w-3.5 h-3.5" /> Disabled</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-500 font-medium">Joined Date</span>
<span className="text-gray-700">
{viewUserModal.user.createdAt ? new Date(viewUserModal.user.createdAt).toLocaleDateString() : "—"}
</span>
</div>
</div>
<div className="flex justify-end">
<Button variant="outline" className="text-gray-600" onClick={() => setViewUserModal({ isOpen: false, user: null })}>
Close
</Button>
</div>
</div>
</div>
)}
{/* Edit User Modal */}
{editUserModal.isOpen && editUserModal.user && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl max-w-md w-full p-6 shadow-xl border border-gray-100">
<h3 className="text-lg font-bold text-gray-900 mb-4">Edit Team Member</h3>
<div className="flex flex-col gap-4">
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase mb-1">Full Name</label>
<input
type="text"
value={editUserModal.name}
onChange={(e) => setEditUserModal(prev => ({ ...prev, name: e.target.value }))}
className="w-full h-10 px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary bg-white text-gray-900 text-sm"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase mb-1">Email Address</label>
<input
type="email"
value={editUserModal.email}
onChange={(e) => setEditUserModal(prev => ({ ...prev, email: e.target.value }))}
className="w-full h-10 px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary bg-white text-gray-900 text-sm"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase mb-1">Select Tenant</label>
<select
value={editUserModal.tenantId}
onChange={(e) => setEditUserModal(prev => ({ ...prev, tenantId: e.target.value }))}
className="w-full h-10 px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary bg-white text-gray-900 text-sm"
>
<option value="">No Tenant (Platform Core)</option>
{tenants.map(t => (
<option key={t.id} value={t.id}>{t.tenant_name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase mb-1">Select Role</label>
<select
value={editUserModal.roleIds[0] || ""}
onChange={(e) => setEditUserModal(prev => ({ ...prev, roleIds: e.target.value ? [Number(e.target.value)] : [] }))}
className="w-full h-10 px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary bg-white text-gray-900 text-sm"
>
<option value="">Choose a role...</option>
{roles.map(r => (
<option key={r.id} value={r.id}>{r.role_name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase mb-1">Status</label>
<div className="flex items-center gap-3 mt-2">
<span className={`text-sm ${editUserModal.status ? 'text-gray-900 font-medium' : 'text-gray-500'}`}>Active</span>
<button
type="button"
onClick={() => setEditUserModal(prev => ({ ...prev, status: !prev.status }))}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${editUserModal.status ? 'bg-primary' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${editUserModal.status ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
<span className={`text-sm ${!editUserModal.status ? 'text-gray-900 font-medium' : 'text-gray-500'}`}>Inactive</span>
</div>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button variant="outline" className="text-gray-600" onClick={() => setEditUserModal({ isOpen: false, user: null, name: "", email: "", tenantId: "", roleIds: [], status: true })}>
Cancel
</Button>
<Button className="bg-primary hover:bg-primary-hover text-white" onClick={handleEditUserSubmit}>
Save Changes
</Button>
</div>
</div>
</div>
)}
{/* Invite Modal */}
{isInviteOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
@@ -515,7 +822,7 @@ export default function UserList() {
onChange={(e) => setInviteTenantId(e.target.value)}
className="w-full h-10 px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary bg-white text-gray-900 text-sm"
>
<option value="">No Tenant (Platform Admin)</option>
<option value="">No Tenant (Platform Core)</option>
{tenants.map(t => (
<option key={t.id} value={t.id}>{t.tenant_name}</option>
))}
@@ -532,8 +839,8 @@ export default function UserList() {
{roles
.filter(r => inviteTenantId ? String(r.tenant_id) === inviteTenantId : !r.tenant_id)
.map(r => (
<option key={r.id} value={r.id}>{r.role_name}</option>
))}
<option key={r.id} value={r.id}>{r.role_name}</option>
))}
</select>
</div>
</div>
@@ -578,7 +885,7 @@ export default function UserList() {
<label className="block text-xs font-semibold text-gray-500 uppercase mb-1">Status</label>
<div className="flex items-center gap-3 mt-2">
<span className={`text-sm ${newRoleStatus ? 'text-gray-900 font-medium' : 'text-gray-500'}`}>Active</span>
<button
<button
type="button"
onClick={() => setNewRoleStatus(!newRoleStatus)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${newRoleStatus ? 'bg-primary' : 'bg-gray-300'}`}
@@ -6,6 +6,8 @@ export const UserRoutes = () => (
<Routes>
<Route index element={<UserList />} />
<Route path="new" element={<NewUser />} />
<Route path=":id" element={<NewUser />} />
<Route path=":id/view" element={<NewUser />} />
<Route path=":id/edit" element={<NewUser />} />
</Routes>
);
+24 -4
View File
@@ -86,10 +86,28 @@ export const protectedRoutes: RouteConfig[] = [
{ path: '/integrations/new', title: 'Create Integration', description: 'Connect a new external system', sidebarRoot: '/integrations' },
{ path: '/integrations/:id/edit', title: 'Edit Integration', description: 'Update integration details', sidebarRoot: '/integrations' },
// Users
{ path: '/users', title: 'Users & Roles', description: 'Manage team members, permissions, and access controls' },
{ path: '/users/new', title: 'Create User', description: 'Add a new team member', sidebarRoot: '/users' },
{ path: '/users/:id/edit', title: 'Edit User', description: 'Update user details and permissions', sidebarRoot: '/users' },
// Users, Roles & Tenants
{ path: '/users', title: 'User Management', description: 'Manage team members, permissions, and access controls' },
{ path: '/users/new', title: 'Invite User', description: 'Invite a new team member and assign roles', sidebarRoot: '/users' },
{ path: '/users/:id', title: 'View User Details', description: 'Detailed member profile, assigned role permissions, and tenant access', sidebarRoot: '/users' },
{ path: '/users/:id/view', title: 'View User Details', description: 'Detailed member profile, assigned role permissions, and tenant access', sidebarRoot: '/users' },
{ path: '/users/:id/edit', title: 'Edit User', description: 'Update user details and assigned permissions', sidebarRoot: '/users' },
// Tenant Management
{ path: '/users/tenants', title: 'Tenant Management', description: 'Manage tenant organizations and multi-tenant isolation', sidebarRoot: '/users/tenants' },
{ path: '/users/tenants/new', title: 'Create Tenant', description: 'Provision a new tenant organization', sidebarRoot: '/users/tenants' },
{ path: '/users/tenants/:id/edit', title: 'Edit Tenant', description: 'Update tenant organization configuration', sidebarRoot: '/users/tenants' },
{ path: '/tenants', title: 'Tenant Management', description: 'Manage tenant organizations and multi-tenant isolation', sidebarRoot: '/users/tenants' },
{ path: '/tenants/new', title: 'Create Tenant', description: 'Provision a new tenant organization', sidebarRoot: '/users/tenants' },
{ path: '/tenants/:id/edit', title: 'Edit Tenant', description: 'Update tenant organization configuration', sidebarRoot: '/users/tenants' },
// Role Management
{ path: '/users/roles', title: 'Role Management', description: 'Define access roles, system permissions, and tenant privileges', sidebarRoot: '/users/roles' },
{ path: '/users/roles/new', title: 'Create Role', description: 'Configure a new access role and permission nodes', sidebarRoot: '/users/roles' },
{ path: '/users/roles/:id/edit', title: 'Edit Role', description: 'Modify role permissions and access nodes', sidebarRoot: '/users/roles' },
{ path: '/roles', title: 'Role Management', description: 'Define access roles, system permissions, and tenant privileges', sidebarRoot: '/users/roles' },
{ path: '/roles/new', title: 'Create Role', description: 'Configure a new access role and permission nodes', sidebarRoot: '/users/roles' },
{ path: '/roles/:id/edit', title: 'Edit Role', description: 'Modify role permissions and access nodes', sidebarRoot: '/users/roles' },
// Reports
{ path: '/reports', title: 'Reports & Analytics', description: 'Comprehensive insights into your product catalog performance' },
@@ -98,6 +116,8 @@ export const protectedRoutes: RouteConfig[] = [
// Settings
{ path: '/settings', title: 'Settings', description: 'System configurations and preferences' },
{ path: '/settings/new', title: 'New Setting', description: 'Add a new system configuration', sidebarRoot: '/settings' },
{ path: '/settings/appearance/theme', title: 'Application Theme', description: 'Choose a theme for the application. Changing the theme updates the application appearance.', sidebarRoot: '/settings' },
{ path: '/settings/theme', title: 'Application Theme', description: 'Choose a theme for the application. Changing the theme updates the application appearance.', sidebarRoot: '/settings' },
// Notifications
{ path: '/notifications', title: 'Notifications', description: 'Activity feed across your catalog, workflows, and team' },
@@ -1,23 +0,0 @@
// src/services/toast/components/CloseButton.tsx
//
// Dismiss button rendered in the top-right corner of every non-loading toast.
// Hover and focus styles are defined in Toast.css (.pim-toast-close).
interface CloseButtonProps {
onClose: () => void;
}
export function CloseButton({ onClose }: CloseButtonProps) {
return (
<button
type="button"
className="pim-toast-close"
onClick={onClose}
aria-label="Dismiss notification"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
</button>
);
}
@@ -1,38 +0,0 @@
// src/services/toast/components/ProgressBar.tsx
//
// Animated progress bar rendered at the bottom of every auto-closing toast.
// Duration is passed from toastProps.autoClose so it always matches the
// actual dismiss timer set in toast.service.ts / toast.config.ts.
// isPaused is passed from toastProps so the animation stays in sync with
// the react-toastify JS timer — both pause and resume together.
//
// Layout and structural styles live in Toast.css (.pim-toast-progress-track,
// .pim-toast-progress). Only dynamic values (color, duration, playState)
// are applied via inline style.
interface ProgressBarProps {
/** Auto-close duration in ms — drives the CSS animation length. */
duration: number;
/** CSS variable string from VARIANT_TOKENS.accent */
color: string;
/** Mirrors react-toastify's isPaused — pauses the animation when the timer is paused. */
isPaused?: boolean;
}
export function ProgressBar({ duration, color, isPaused = false }: ProgressBarProps) {
return (
<div
className="pim-toast-progress-track"
style={{ backgroundColor: `color-mix(in srgb, ${color} 18%, transparent)` }}
>
<div
className="pim-toast-progress"
style={{
backgroundColor: color,
animationDuration: `${duration}ms`,
animationPlayState: isPaused ? "paused" : "running",
}}
/>
</div>
);
}
-300
View File
@@ -1,300 +0,0 @@
/* ─── Toast enter / exit animations ──────────────────────────────────────── */
@keyframes toast-in {
0% { opacity: 0; transform: translateX(calc(100% + 1rem)) scale(0.96); }
60% { opacity: 1; transform: translateX(-4px) scale(1.01); }
100% { opacity: 1; transform: translateX(0) scale(1); }
}
@keyframes toast-out {
0% { opacity: 1; transform: translateX(0) scale(1); }
100% { opacity: 0; transform: translateX(calc(100% + 1rem)) scale(0.96); }
}
/* ─── Toast shell ─────────────────────────────────────────────────────────── */
.pim-toast {
position: relative;
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 40px 14px 14px;
border-radius: 12px;
border: 1px solid var(--color-border, #e5e7eb);
background-color: var(--color-primary-ghost, #DDD6FE);
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.08), 0 2px 6px -1px rgba(0, 0, 0, 0.05);
overflow: hidden;
transition: transform 0.18s ease, box-shadow 0.18s ease;
min-width: 300px;
max-width: 400px;
/* border-left color is set via inline style (dynamic token) */
border-left-width: 3.5px;
border-left-style: solid;
}
.pim-toast:hover {
transform: translateY(-2px);
box-shadow:
0 20px 40px -8px rgba(0, 0, 0, 0.14),
0 8px 16px -4px rgba(0, 0, 0, 0.08);
}
/* ─── Icon container ──────────────────────────────────────────────────────── */
.pim-toast-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 1px;
/* background-color is set via inline style (dynamic token) */
}
/* ─── Text content ────────────────────────────────────────────────────────── */
.pim-toast-content {
flex: 1;
min-width: 0;
padding-top: 1px;
}
.pim-toast-title {
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.35;
color: var(--color-foreground, #111827);
letter-spacing: -0.01em;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.pim-toast-description {
margin: 3px 0 0;
font-size: 0.75rem;
line-height: 1.45;
color: var(--color-muted-foreground, #6b7280);
font-weight: 400;
}
/* ─── HTTP status badge ───────────────────────────────────────────────────── */
.pim-toast-status-badge {
display: inline-flex;
align-items: center;
font-size: 0.6875rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
padding: 1px 5px;
border-radius: 4px;
border: 1px solid currentColor;
opacity: 0.85;
flex-shrink: 0;
/* color and border-color are set via inline style (dynamic token) */
}
/* ─── Action button ───────────────────────────────────────────────────────── */
.pim-toast-action {
display: inline-flex;
align-items: center;
margin-top: 6px;
padding: 2px 0;
font-size: 0.75rem;
font-weight: 600;
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
opacity: 0.9;
transition: opacity 0.15s;
/* color is set via inline style (dynamic token) */
}
.pim-toast-action:hover {
opacity: 1;
}
.pim-toast-action:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
border-radius: 2px;
}
/* ─── Progress bar ────────────────────────────────────────────────────────── */
@keyframes toast-progress {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
.pim-toast-progress-track {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
border-radius: 0 0 12px 12px;
overflow: hidden;
/* background-color is set via inline style (dynamic token) */
}
.pim-toast-progress {
height: 100%;
width: 100%;
border-radius: 0 0 12px 12px;
transform-origin: left;
animation: toast-progress linear forwards;
/* background-color, animationDuration, animationPlayState via inline style */
}
/* ─── Animated checkmark (SVG stroke-dashoffset draw) ────────────────────── */
@keyframes check-draw {
from { stroke-dashoffset: 24; }
to { stroke-dashoffset: 0; }
}
@keyframes check-circle {
from { stroke-dashoffset: 66; opacity: 0; }
to { stroke-dashoffset: 0; opacity: 1; }
}
.pim-check-circle {
stroke-dasharray: 66;
stroke-dashoffset: 66;
animation: check-circle 0.45s cubic-bezier(0.4, 0, 0.2, 1) 0.05s forwards;
}
.pim-check-mark {
stroke-dasharray: 24;
stroke-dashoffset: 24;
animation: check-draw 0.3s cubic-bezier(0.4, 0, 0.2, 1) 0.35s forwards;
}
/* ─── X mark (error icon) ─────────────────────────────────────────────────── */
@keyframes x-draw {
from { stroke-dashoffset: 20; opacity: 0; }
to { stroke-dashoffset: 0; opacity: 1; }
}
.pim-x-line {
stroke-dasharray: 20;
stroke-dashoffset: 20;
animation: x-draw 0.25s cubic-bezier(0.4, 0, 0.2, 1) 0.3s forwards;
}
/* ─── Warning / Info icon pop ─────────────────────────────────────────────── */
@keyframes icon-pop {
0% { transform: scale(0.5); opacity: 0; }
70% { transform: scale(1.15); opacity: 1; }
100% { transform: scale(1); }
}
.pim-icon-pop {
animation: icon-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) 0.1s both;
}
/* ─── Loading spinner ─────────────────────────────────────────────────────── */
@keyframes toast-spin {
to { transform: rotate(360deg); }
}
.pim-spinner {
animation: toast-spin 0.75s linear infinite;
}
/* ─── Close button ────────────────────────────────────────────────────────── */
.pim-toast-close {
position: absolute;
top: 10px;
right: 10px;
width: 22px;
height: 22px;
border-radius: 6px;
border: none;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-muted-foreground, #6b7280);
opacity: 0.6;
transition: opacity 0.15s, background 0.15s;
padding: 0;
flex-shrink: 0;
}
.pim-toast-close:hover {
opacity: 1;
background: color-mix(in srgb, var(--color-foreground, #111827) 8%, transparent);
}
.pim-toast-close:focus-visible {
outline: 2px solid var(--color-primary, #7C3AED);
outline-offset: 2px;
}
/* ─── Toastify container overrides ───────────────────────────────────────── */
/* Strip ALL default toastify chrome — our component owns 100% of the visual */
.pim-toast-container {
padding: 0 !important;
width: auto !important;
min-width: 340px;
max-width: 420px;
}
.pim-toast-container .Toastify__toast {
padding: 0 !important;
margin: 0 0 0.625rem !important;
background: transparent !important;
box-shadow: none !important;
border-radius: 0 !important;
min-height: unset !important;
font-family: inherit !important;
cursor: default !important;
animation: toast-in 0.42s cubic-bezier(0.34, 1.2, 0.64, 1) both !important;
}
.pim-toast-container .Toastify__toast--default,
.pim-toast-container .Toastify__toast--success,
.pim-toast-container .Toastify__toast--error,
.pim-toast-container .Toastify__toast--warning,
.pim-toast-container .Toastify__toast--info {
background: transparent !important;
}
/* Hide toastify's own progress bar visually — keep animation running so animationend fires */
.pim-toast-container .Toastify__progress-bar--wrp {
opacity: 0 !important;
pointer-events: none !important;
}
/* Hide toastify's own close button — we render our own */
.pim-toast-container .Toastify__close-button {
display: none !important;
}
.pim-toast-container .Toastify__toast-body {
padding: 0 !important;
margin: 0 !important;
width: 100% !important;
}
/* Slide-out on dismiss */
.pim-toast-container .Toastify__toast--close-on-click,
.pim-toast-container .Toastify__slide-exit {
animation: toast-out 0.28s cubic-bezier(0.4, 0, 1, 1) forwards !important;
}
+121 -59
View File
@@ -1,50 +1,77 @@
// src/services/toast/components/Toast.tsx
//
// Render components for Toast notifications. Contains icons, ProgressBar, CloseButton.
import "./Toast.css";
import "../styles/toast.css";
import type { ToastContentProps } from "react-toastify";
import { ProgressBar } from "./ProgressBar";
import { CloseButton } from "./CloseButton";
import { SuccessIcon, ErrorIcon, WarningIcon, InfoIcon, LoadingSpinner } from "../toast.icons";
import { VARIANT_TOKENS, resolvePayload } from "../toast.utils";
import type { ToastVariant, ToastPayload } from "../toast.types";
import { VARIANT_TOKENS, resolvePayload } from "../utils/toast.utils";
import type { ToastVariant, ToastPayload } from "../types/toast.types";
// ─── ARIA role mapping ────────────────────────────────────────────────────────
// error / warning → role="alert" aria-live="assertive" (interrupts the user)
// success / info → role="status" aria-live="polite" (non-disruptive)
// loading → role="status" aria-live="polite" (non-disruptive)
// ─── SVG Variant Icons ────────────────────────────────────────────────────────
const ARIA_ROLE: Record<ToastVariant, "alert" | "status"> = {
error: "alert",
warning: "alert",
success: "status",
info: "status",
loading: "status",
};
function SuccessIcon({ color }: { color: string }) {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
);
}
const ARIA_LIVE: Record<ToastVariant, "assertive" | "polite"> = {
error: "assertive",
warning: "assertive",
success: "polite",
info: "polite",
loading: "polite",
};
function ErrorIcon({ color }: { color: string }) {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
);
}
// ─── HTTP status badge ────────────────────────────────────────────────────────
function WarningIcon({ color }: { color: string }) {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
);
}
function InfoIcon({ color }: { color: string }) {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
);
}
function LoadingSpinner({ color }: { color: string }) {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="animate-spin">
<line x1="12" y1="2" x2="12" y2="6" />
<line x1="12" y1="18" x2="12" y2="22" />
<line x1="4.93" y1="4.93" x2="7.76" y2="7.76" />
<line x1="16.24" y1="16.24" x2="19.07" y2="19.07" />
<line x1="2" y1="12" x2="6" y2="12" />
<line x1="18" y1="12" x2="22" y2="12" />
<line x1="4.93" y1="19.07" x2="7.76" y2="16.24" />
<line x1="16.24" y1="7.76" x2="19.07" y2="4.93" />
</svg>
);
}
// ─── Sub-Components ───────────────────────────────────────────────────────────
function StatusBadge({ status, color }: { status: number; color: string }) {
return (
<span
className="pim-toast-status-badge"
style={{ color, borderColor: color }}
aria-label={`HTTP ${status}`}
>
<span className="pim-toast-status-badge" style={{ color, borderColor: color }} aria-label={`HTTP ${status}`}>
{status}
</span>
);
}
// ─── Action button ────────────────────────────────────────────────────────────
function ActionButton({ label, onClick, color }: { label: string; onClick: () => void; color: string }) {
return (
<button
@@ -61,7 +88,57 @@ function ActionButton({ label, onClick, color }: { label: string; onClick: () =>
);
}
// ─── Toast body ───────────────────────────────────────────────────────────────
function CloseButton({ onClose }: { onClose: () => void }) {
return (
<button
type="button"
className="pim-toast-close"
aria-label="Close notification"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
);
}
function ProgressBar({ duration, color, isPaused }: { duration: number; color: string; isPaused?: boolean }) {
return (
<div
className="pim-toast-progress-bar"
style={{
backgroundColor: color,
animationDuration: `${duration}ms`,
animationPlayState: isPaused ? "paused" : "running",
}}
/>
);
}
// ─── ARIA Roles & Live Regions ────────────────────────────────────────────────
const ARIA_ROLE: Record<ToastVariant, "alert" | "status"> = {
error: "alert",
warning: "alert",
success: "status",
info: "status",
loading: "status",
};
const ARIA_LIVE: Record<ToastVariant, "assertive" | "polite"> = {
error: "assertive",
warning: "assertive",
success: "polite",
info: "polite",
loading: "polite",
};
// ─── Toast Body ───────────────────────────────────────────────────────────────
interface ToastBodyProps {
variant: ToastVariant;
@@ -72,17 +149,14 @@ interface ToastBodyProps {
}
function ToastBody({ variant, payload, closeToast, autoClose, isPaused }: ToastBodyProps) {
const tokens = VARIANT_TOKENS[variant];
const tokens = VARIANT_TOKENS[variant];
const isLoading = variant === "loading";
const { title, description } = resolvePayload(payload, tokens.label);
// httpStatus and action are only present when payload is a full BusinessNotification.
const httpStatus = typeof payload === "object" ? payload.httpStatus : undefined;
const action = typeof payload === "object" ? payload.action : undefined;
const action = typeof payload === "object" ? payload.action : undefined;
// For loading toasts autoClose is always false — never derive a numeric duration from it.
// For all other variants autoClose must be a number; fall back to 3000 only as a safety net.
const duration = (!isLoading && typeof autoClose === "number") ? autoClose : 3000;
const duration = (!isLoading && typeof autoClose === "number") ? autoClose : 6000;
return (
<div
@@ -92,33 +166,21 @@ function ToastBody({ variant, payload, closeToast, autoClose, isPaused }: ToastB
aria-atomic="true"
style={{ borderLeftColor: tokens.accent }}
>
{/* Variant icon */}
<div
className="pim-toast-icon"
style={{ backgroundColor: tokens.accentBg }}
aria-hidden="true"
>
{variant === "success" && <SuccessIcon color={tokens.accentText} />}
{variant === "error" && <ErrorIcon color={tokens.accentText} />}
{variant === "warning" && <WarningIcon color={tokens.accentText} />}
{variant === "info" && <InfoIcon color={tokens.accentText} />}
<div className="pim-toast-icon" style={{ backgroundColor: tokens.accentBg }} aria-hidden="true">
{variant === "success" && <SuccessIcon color={tokens.accentText} />}
{variant === "error" && <ErrorIcon color={tokens.accentText} />}
{variant === "warning" && <WarningIcon color={tokens.accentText} />}
{variant === "info" && <InfoIcon color={tokens.accentText} />}
{variant === "loading" && <LoadingSpinner color={tokens.accentText} />}
</div>
{/* Content */}
<div className="pim-toast-content">
<p className="pim-toast-title">
{httpStatus !== undefined && (
<StatusBadge status={httpStatus} color={tokens.accentText} />
)}
{httpStatus !== undefined && <StatusBadge status={httpStatus} color={tokens.accentText} />}
{title}
</p>
{description && (
<p className="pim-toast-description">{description}</p>
)}
{action && (
<ActionButton label={action.label} onClick={action.onClick} color={tokens.accentText} />
)}
{description && <p className="pim-toast-description">{description}</p>}
{action && <ActionButton label={action.label} onClick={action.onClick} color={tokens.accentText} />}
</div>
{!isLoading && closeToast && <CloseButton onClose={closeToast} />}
@@ -1,8 +1,6 @@
// src/services/toast/components/ToastContainer.tsx
//
// Re-exports react-toastify's ToastContainer so that toast.provider.tsx
// does not import directly from react-toastify. All react-toastify imports
// are consolidated in toast.service.ts (runtime) and here (container).
// Re-exports react-toastify's ToastContainer for portal rendering.
export { ToastContainer } from "react-toastify";
export type { ToastContainerProps } from "react-toastify";
@@ -1,12 +0,0 @@
// src/services/toast/components/ToastIcons.tsx
//
// Re-exports all animated SVG icons from toast.icons.tsx.
// Keeps the components/ folder self-contained.
export {
SuccessIcon,
ErrorIcon,
WarningIcon,
InfoIcon,
LoadingSpinner,
} from "../toast.icons";
@@ -0,0 +1,41 @@
// src/services/toast/components/ToastProvider.tsx
//
// Mounts ToastContainer globally and cleans up orphaned loading toasts on route navigation.
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
import { ToastContainer } from "./ToastContainer";
import { dismissLoadingToasts } from "../services/toast.service";
import "../styles/toast.css";
function useToastRouteCleanup() {
const location = useLocation();
useEffect(() => {
// Only clean up orphaned loading toasts when route changes,
// allowing success/error notifications to remain visible.
dismissLoadingToasts();
}, [location.pathname]);
}
export function ToastProvider() {
useToastRouteCleanup();
return (
<ToastContainer
position="top-right"
autoClose={6000}
closeButton={false}
icon={false}
newestOnTop
pauseOnFocusLoss={false}
pauseOnHover
draggable={false}
limit={5}
className="pim-toast-container"
toastClassName={() => ""}
toastStyle={{ background: "transparent", boxShadow: "none", padding: 0 }}
style={{ width: "auto", minWidth: "340px", maxWidth: "420px" }}
/>
);
}
+2 -3
View File
@@ -1,9 +1,8 @@
// src/services/toast/hooks/useToast.ts
//
// Returns the notify API for use inside React components.
// No additional logic — notify is already a stable singleton.
// React Hook exposing the stable notify API for component usage.
import { notify } from "../toast.service";
import { notify } from "../services/toast.service";
export function useToast() {
return { notify };
@@ -1,33 +0,0 @@
// src/services/toast/hooks/useToastRouteCleanup.ts
//
// Dismisses all active loading toasts when the user navigates to a new route.
// Prevents orphan loading notifications that would otherwise persist forever.
//
// Usage: rendered once inside ToastProvider (which is inside the Router).
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { toast } from "react-toastify";
/**
* Tracks the set of loading toast IDs created via notify.loading().
* toast.service.ts registers IDs here; this hook cleans them up on navigation.
*/
export const loadingToastIds = new Set<string | number>();
export function useToastRouteCleanup(): void {
const location = useLocation();
const prevPath = useRef<string>(location.pathname);
useEffect(() => {
// Only act on actual route changes, not on the initial mount.
if (prevPath.current === location.pathname) return;
prevPath.current = location.pathname;
// Dismiss every tracked loading toast.
for (const id of loadingToastIds) {
toast.dismiss(id);
}
loadingToastIds.clear();
}, [location.pathname]);
}
+14 -21
View File
@@ -1,29 +1,22 @@
// src/services/toast/index.ts
//
// ─── PUBLIC API — FROZEN ──────────────────────────────────────────────────────
//
// This is the only surface feature modules should ever import from.
// Nothing else from the toast infrastructure is public.
//
// Implementation details that are NOT exported:
// toast.registry — internal dispatcher
// toast.errors — internal error normalization
// toast.internal — internal low-level ops
// toast.promise — internal promise lifecycle
// toast.utils — internal UI helpers
// toast.config — internal configuration
// toast.factory — internal message factory
// toast.types (internal types) — BusinessNotification, ToastContent, etc.
// Feature modules import ONLY from this barrel file.
// Nothing else from internal toast infrastructure is public.
// ─── Runtime API ─────────────────────────────────────────────────────────────
export { notify } from "./toast.service";
// Runtime API
export { notify } from "./services/toast.service";
// ─── Provider ─────────────────────────────────────────────────────────────────
export { ToastProvider } from "./toast.provider";
// Provider
export { ToastProvider } from "./components/ToastProvider";
// ─── Hook ─────────────────────────────────────────────────────────────────────
// Hook
export { useToast } from "./hooks/useToast";
// ─── Public types ─────────────────────────────────────────────────────────────
// Only the types feature modules need to annotate their own code.
export type { ToastOptions, ToastAction, ToastMetadata, PromiseToastOptions } from "./toast.types";
// Public Types
export type {
ToastOptions,
ToastAction,
ToastMetadata,
PromiseToastOptions,
} from "./types/toast.types";
-7
View File
@@ -1,7 +0,0 @@
// src/services/toast/messages/index.ts
//
// The toast infrastructure layer only owns system-level messages.
// Domain messages (Product, Category, Asset, etc.) belong in their
// respective feature modules.
export { SystemMessages } from "./system.messages";
@@ -1,45 +0,0 @@
// src/services/toast/messages/system.messages.ts
//
// Infrastructure-level system messages only.
// No domain entities (Product, Category, Asset, etc.) belong here.
import { createError, createWarning, createInfo } from "../toast.factory";
import type { BusinessNotification } from "../toast.types";
export const SystemMessages = {
networkError: (): BusinessNotification =>
createError("Network Error", "Unable to connect. Please check your internet connection."),
timeout: (): BusinessNotification =>
createError("Request Timed Out", "The server took too long to respond. Please try again."),
internalServerError: (msg?: string, httpStatus?: number): BusinessNotification =>
createError("Server Error", msg ?? "An internal server error occurred. Please try again later.", { httpStatus }),
unauthorized: (httpStatus = 401): BusinessNotification =>
createWarning("Unauthorized", "Your session has expired. Please log in again.", { httpStatus }),
permissionDenied: (httpStatus = 403): BusinessNotification =>
createWarning("Permission Denied", "You do not have permission to perform this action.", { httpStatus }),
notFound: (msg?: string, httpStatus = 404): BusinessNotification =>
createError("Not Found", msg ?? "The requested resource could not be found.", { httpStatus }),
conflict: (msg?: string, httpStatus = 409): BusinessNotification =>
createError("Conflict", msg ?? "This record already exists or conflicts with existing data.", { httpStatus }),
duplicateRecord: (): BusinessNotification =>
createError("Duplicate Record", "A record with the same details already exists."),
validationError: (msg?: string, httpStatus?: number): BusinessNotification =>
createError("Validation Error", msg ?? "Please check the form fields and try again.", { httpStatus }),
tooManyRequests: (httpStatus = 429): BusinessNotification =>
createError("Too Many Requests", "You have made too many requests. Please wait and try again.", { httpStatus }),
unknownError: (msg?: string, httpStatus?: number): BusinessNotification =>
createError("Something Went Wrong", msg ?? "An unexpected error occurred. Please try again.", { httpStatus }),
info: (msg: string): BusinessNotification =>
createInfo("Info", msg),
};
@@ -1,17 +1,19 @@
// src/services/toast/toast.service.ts
// src/services/toast/services/toast.service.ts
//
// Assembles and exports the single public `notify` API.
// All implementation details are in toast.internal.ts / toast.promise.ts.
// Nothing from this file except `notify` should be re-exported from index.ts.
// Core Business Service Engine — Pure TS (No JSX).
// Manages notification dispatching, deduplication, priority rules, and promise handles.
import { toast, type Id } from "react-toastify";
import { SuccessToast, ErrorToast, WarningToast, InfoToast, LoadingToast } from "./components/Toast";
import { DEFAULT_TOAST_OPTIONS, isSuppressedError, TOAST_PRIORITY } from "./toast.config";
import { normalizeError } from "./toast.errors";
import { dispatchNotification } from "./toast.registry";
import { showLoading, updateToast } from "./toast.internal";
import { notifyPromise } from "./toast.promise";
import { resolveToastId, isRecentlySuppressed, createToastHash } from "./toast.utils";
import { SuccessToast, ErrorToast, WarningToast, InfoToast, LoadingToast } from "../components/Toast";
import {
DEFAULT_TOAST_OPTIONS,
TOAST_PRIORITY,
isSuppressedError,
normalizeError,
resolveToastId,
isRecentlySuppressed,
createToastHash,
} from "../utils/toast.utils";
import type {
ToastContent,
ToastOptions,
@@ -19,9 +21,18 @@ import type {
BusinessNotification,
ToastMetadata,
PromiseToastOptions,
} from "./toast.types";
ToastVariant,
} from "../types/toast.types";
// ─── Internal render helpers ──────────────────────────────────────────────────
// Active loading toast instances map
const activeLoadingIds = new Set<Id>();
export function dismissLoadingToasts() {
activeLoadingIds.forEach((id) => toast.dismiss(id));
activeLoadingIds.clear();
}
// ─── Internal Helpers ─────────────────────────────────────────────────────────
function resolveMessage(content: ToastContent): string {
return typeof content === "string" ? content : content.title;
@@ -42,12 +53,12 @@ function baseOptions(
variant?: string,
metadata?: ToastMetadata
) {
const autoClose = options?.autoClose !== undefined ? options.autoClose : (duration ?? DEFAULT_TOAST_OPTIONS.autoClose);
const title = typeof content === "string" ? content : content.title;
const autoClose = options?.autoClose !== undefined ? options.autoClose : (duration ?? DEFAULT_TOAST_OPTIONS.autoClose);
const title = typeof content === "string" ? content : content.title;
const description = typeof content === "object" ? content.description : undefined;
const toastId = resolveToastId(options, metadata, variant ?? "info", title, description);
// Strip `id` and `toastId` from the spread — react-toastify only understands `toastId`.
const { id: _id, toastId: _toastId, ...restOptions } = options ?? {};
const toastId = resolveToastId(options, metadata, variant ?? "info", title, description);
const { id: _id, toastId: _toastId, data: _data, ...restOptions } = options ?? {};
return {
...DEFAULT_TOAST_OPTIONS,
...restOptions,
@@ -67,7 +78,6 @@ function _showError(content: ToastContent, options?: ToastOptions, duration?: nu
console.warn("Toast suppressed (network/server error):", message.replace(/[\r\n]/g, " "));
return "";
}
// 2-second suppression window: prevent rapid-fire duplicate error toasts.
const hash = createToastHash("error", metadata?.feature, metadata?.action, message,
typeof content === "object" ? content.description : undefined);
if (isRecentlySuppressed(hash)) return "";
@@ -82,54 +92,134 @@ function _showInfo(content: ToastContent, options?: ToastOptions, duration?: num
return toast(InfoToast, { ...baseOptions(content, options, duration, "info", metadata), type: "info" });
}
export function showLoading(content: ToastContent, options?: ToastOptions): LoadingHandle {
const toastId = options?.id ?? options?.toastId ?? `loading-${Date.now()}`;
const payloadData: BusinessNotification = typeof content === "string"
? { title: content, variant: "loading" as ToastVariant }
: { ...content, variant: "loading" as ToastVariant };
const { id: _id, toastId: _toastId, data: _data, ...restOptions } = options ?? {};
const id = toast(LoadingToast, {
...DEFAULT_TOAST_OPTIONS,
...restOptions,
toastId,
autoClose: false,
closeOnClick: false,
draggable: false,
data: payloadData,
type: "default",
});
activeLoadingIds.add(id);
return {
id,
done(resContent: ToastContent, resOptions?: ToastOptions) {
activeLoadingIds.delete(id);
updateToast(id, "success", resContent, resOptions);
},
error(errContent: ToastContent | unknown, errOptions?: ToastOptions) {
activeLoadingIds.delete(id);
const payload: BusinessNotification = isBusinessNotification(errContent)
? errContent
: typeof errContent === "string"
? { title: errContent, variant: "error" as ToastVariant }
: normalizeError(errContent);
updateToast(id, "error", payload, errOptions);
},
};
}
export function updateToast(
id: Id,
variant: "success" | "error" | "warning" | "info",
content: ToastContent,
options?: ToastOptions
): void {
const componentMap = {
success: SuccessToast,
error: ErrorToast,
warning: WarningToast,
info: InfoToast,
};
const autoClose = options?.autoClose !== undefined ? options.autoClose : DEFAULT_TOAST_OPTIONS.autoClose;
const { id: _id, toastId: _toastId, data: _data, ...restOptions } = options ?? {};
const dataPayload: BusinessNotification = typeof content === "string"
? { title: content, variant }
: { ...content, variant };
activeLoadingIds.delete(id);
toast.update(id, {
...restOptions,
render: componentMap[variant],
data: dataPayload,
type: variant,
autoClose,
closeOnClick: true,
});
}
function trigger(
message: BusinessNotification,
options?: ToastOptions,
metadata?: ToastMetadata
) {
const final = dispatchNotification(message, metadata);
const isCritical = message.priority === TOAST_PRIORITY.CRITICAL;
// Priority enforcement: critical notifications are never suppressed by the
// 2-second deduplication window. They always fire regardless of recency.
const isCritical = final.priority === TOAST_PRIORITY.CRITICAL;
// Pass the full enriched BusinessNotification as data so Toast.tsx can access
// httpStatus, action, priority, retryable — not just title and description.
const payload: BusinessNotification = {
title: final.title,
description: final.description,
httpStatus: final.httpStatus,
action: final.action,
priority: final.priority,
retryable: final.retryable,
dismissible: final.dismissible,
metadata: final.metadata,
title: message.title,
description: message.description,
httpStatus: message.httpStatus,
action: message.action,
priority: message.priority,
retryable: message.retryable,
dismissible: message.dismissible,
metadata: message.metadata ?? metadata,
};
const duration = final.duration;
const meta = final.metadata;
const duration = message.duration;
const meta = message.metadata ?? metadata;
// For critical notifications, bypass the suppression window by temporarily
// clearing the hash from the recent-hashes map before calling _show*.
// We achieve this by passing a unique options.id so resolveToastId uses
// Priority 1 (explicit id) and the hash-based suppression is never checked.
const criticalOptions: ToastOptions | undefined = isCritical
? { ...options, id: `critical-${Date.now()}` }
: options;
switch (final.variant) {
switch (message.variant) {
case "success": return _showSuccess(payload, criticalOptions, duration, meta);
case "error": return _showError(payload, criticalOptions, duration, meta);
case "error": return _showError(payload, criticalOptions, duration, meta);
case "warning": return _showWarning(payload, criticalOptions, duration, meta);
case "info": return _showInfo(payload, criticalOptions, duration, meta);
case "info": return _showInfo(payload, criticalOptions, duration, meta);
case "loading": return showLoading(payload, options);
default: return _showInfo(payload, criticalOptions, duration, meta);
default: return _showInfo(payload, criticalOptions, duration, meta);
}
}
// ─── Public API ───────────────────────────────────────────────────────────────
//
// This is the frozen contract. Do not rename methods, change parameter shapes,
// or add new entries without a deliberate versioning decision.
export function notifyPromise<T>(
promise: Promise<T> | (() => Promise<T>),
config: PromiseToastOptions<T>
): Promise<T> {
const handle = showLoading(config.loading, config.options);
const actualPromise = typeof promise === "function" ? promise() : promise;
return actualPromise
.then((result) => {
const resolvedSuccess =
typeof config.success === "function" ? config.success(result) : config.success;
handle.done(resolvedSuccess as ToastContent, config.options);
return result;
})
.catch((err: unknown) => {
const resolvedError =
typeof config.error === "function" ? config.error(err) : config.error;
handle.error(resolvedError as ToastContent | unknown, config.options);
throw err;
});
}
// ─── Public API Object ─────────────────────────────────────────────────────────
export const notify = {
success(content: ToastContent, options?: ToastOptions, metadata?: ToastMetadata) {
@@ -183,13 +273,12 @@ export const notify = {
},
dismiss(id: Id): void {
activeLoadingIds.delete(id);
toast.dismiss(id);
},
dismissAll(): void {
activeLoadingIds.clear();
toast.dismiss();
},
};
// Internal re-export for Toast.tsx which needs LoadingToast for its render map
export { LoadingToast };
+121
View File
@@ -0,0 +1,121 @@
/* src/services/toast/styles/toast.css */
/* Custom styling for PIM Toast Notifications */
.pim-toast-container {
z-index: 9999;
font-family: var(--font-sans, 'Inter', system-ui, -apple-system, sans-serif);
}
.pim-toast {
display: flex;
align-items: flex-start;
gap: 12px;
background: var(--color-surface, #ffffff);
color: var(--color-foreground, #111827);
border: 1px solid var(--color-border, #e5e7eb);
border-left-width: 4px;
border-radius: 8px;
padding: 12px 16px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
position: relative;
overflow: hidden;
width: 100%;
}
.pim-toast-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 2px;
}
.pim-toast-content {
flex: 1;
min-width: 0;
}
.pim-toast-title {
font-weight: 600;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--color-foreground, #111827);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.pim-toast-description {
font-size: 0.8125rem;
line-height: 1.125rem;
color: var(--color-muted-foreground, #6b7280);
margin-top: 2px;
}
.pim-toast-status-badge {
font-size: 0.75rem;
font-weight: 700;
padding: 1px 5px;
border-radius: 4px;
border: 1px solid currentColor;
text-transform: uppercase;
letter-spacing: 0.05em;
display: inline-block;
}
.pim-toast-action {
background: transparent;
border: none;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
padding: 4px 0;
margin-top: 4px;
text-decoration: underline;
display: inline-block;
}
.pim-toast-action:hover {
opacity: 0.8;
}
.pim-toast-close {
background: transparent;
border: none;
color: var(--color-muted-foreground, #9ca3af);
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease;
flex-shrink: 0;
}
.pim-toast-close:hover {
color: var(--color-foreground, #111827);
}
.pim-toast-progress-bar {
position: absolute;
bottom: 0;
left: 0;
height: 3px;
width: 100%;
transform-origin: left;
animation: pim-toast-progress linear forwards;
}
@keyframes pim-toast-progress {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
-73
View File
@@ -1,73 +0,0 @@
// src/services/toast/toast.config.ts
//
// All global configuration and constants for the toast system.
// Single source of truth for durations, positions, limits, and suppression rules.
import type { ToastOptions } from "./toast.types";
// ─── Default options ──────────────────────────────────────────────────────────
export const DEFAULT_TOAST_OPTIONS: Required<Pick<ToastOptions, "autoClose">> = {
autoClose: 3000,
};
// ─── Durations ────────────────────────────────────────────────────────────────
export const TOAST_DURATIONS = {
SHORT: 2000,
MEDIUM: 4000,
LONG: 7000,
PERSISTENT: false as const,
} as const;
// ─── Priority ─────────────────────────────────────────────────────────────────
export const TOAST_PRIORITY = {
LOW: "low",
NORMAL: "normal",
HIGH: "high",
CRITICAL: "critical",
} as const;
// ─── Positions ────────────────────────────────────────────────────────────────
export const TOAST_POSITIONS = {
TOP_RIGHT: "top-right",
TOP_CENTER: "top-center",
BOTTOM_RIGHT: "bottom-right",
BOTTOM_CENTER: "bottom-center",
} as const;
// ─── Limits ───────────────────────────────────────────────────────────────────
export const MAX_VISIBLE_TOASTS = 5;
// ─── Animation timings ───────────────────────────────────────────────────────
export const ANIMATION_TIMINGS = {
ENTER: 300,
EXIT: 300,
} as const;
// ─── Suppression ─────────────────────────────────────────────────────────────
// Network / server error strings that should be silently suppressed.
// Exact-match strings (case-insensitive full-word or full-phrase checks).
// Do NOT use bare HTTP status codes like "500" — they match substrings such as
// "Error code 5001". Status-code suppression is handled by isAxiosError guards
// in toast.errors.ts before a toast is ever created.
export const SUPPRESSED_ERROR_PATTERNS: ReadonlyArray<string> = [
"Network Error",
"Network connection failed",
"ERR_NETWORK",
"Failed to fetch",
"server connection",
];
export function isSuppressedError(message: string): boolean {
if ((window as Window & { __hasConnectionError?: boolean }).__hasConnectionError) return true;
const lower = message.toLowerCase();
return SUPPRESSED_ERROR_PATTERNS.some((pattern) =>
lower.includes(pattern.toLowerCase())
);
}
-103
View File
@@ -1,103 +0,0 @@
// src/services/toast/toast.errors.ts
//
// Centralized error normalization.
// Parses all error types into standard BusinessNotification payloads.
// Supports: Axios, Fetch, Network, Timeout, Abort, Validation, Backend, Unknown.
//
// Separation of concerns:
// isAxiosError() — type guard (parsing)
// normalizeError() — maps parsed error to BusinessNotification (mapping)
import { SystemMessages } from "./messages/system.messages";
import type { BusinessNotification } from "./toast.types";
// ─── Axios error shape ────────────────────────────────────────────────────────
interface AxiosErrorResponse {
status: number;
data?: {
message?: string;
errors?: unknown[];
code?: string;
};
}
interface AxiosErrorShape {
isAxiosError: true;
code?: string;
message?: string;
name?: string;
response?: AxiosErrorResponse;
}
function isAxiosError(error: unknown): error is AxiosErrorShape {
return (
typeof error === "object" &&
error !== null &&
(error as Record<string, unknown>)["isAxiosError"] === true
);
}
// ─── Normalizer ───────────────────────────────────────────────────────────────
export function normalizeError(error: unknown): BusinessNotification {
// Axios errors
if (isAxiosError(error)) {
if (
error.code === "ECONNABORTED" ||
error.message?.toLowerCase().includes("timeout")
) {
return SystemMessages.timeout();
}
if (error.message === "Network Error") {
return SystemMessages.networkError();
}
if (error.response) {
const { status, data } = error.response;
const backendMessage = data?.message;
switch (status) {
case 400:
return SystemMessages.validationError(backendMessage, status);
case 401:
return SystemMessages.unauthorized(status);
case 403:
return SystemMessages.permissionDenied(status);
case 404:
return SystemMessages.notFound(backendMessage, status);
case 409:
return SystemMessages.conflict(backendMessage, status);
case 422:
return SystemMessages.validationError(backendMessage, status);
case 429:
return SystemMessages.tooManyRequests(status);
case 500:
case 502:
case 503:
case 504:
return SystemMessages.internalServerError(backendMessage, status);
default:
return SystemMessages.unknownError(backendMessage, status);
}
}
}
// Fetch AbortError
if (error instanceof Error && error.name === "AbortError") {
return SystemMessages.unknownError("The request was cancelled.");
}
// Standard JS errors
if (error instanceof Error) {
return SystemMessages.unknownError(error.message);
}
// Plain strings
if (typeof error === "string") {
return SystemMessages.unknownError(error);
}
return SystemMessages.unknownError();
}
-55
View File
@@ -1,55 +0,0 @@
// src/services/toast/toast.factory.ts
import { TOAST_DURATIONS, TOAST_PRIORITY } from "./toast.config";
import type { BusinessNotification, ToastVariant } from "./toast.types";
/**
* Message Factory
* Prevents duplicated message objects and ensures consistency.
*/
function createMessage(
variant: ToastVariant,
title: string,
description?: string,
overrides?: Partial<BusinessNotification>
): BusinessNotification {
return {
title,
description,
variant,
duration: TOAST_DURATIONS.MEDIUM,
priority: TOAST_PRIORITY.NORMAL,
dismissible: true,
retryable: false,
...overrides,
};
}
export function createSuccess(title: string, description?: string, overrides?: Partial<BusinessNotification>) {
return createMessage("success", title, description, overrides);
}
export function createError(title: string, description?: string, overrides?: Partial<BusinessNotification>) {
return createMessage("error", title, description, {
duration: TOAST_DURATIONS.LONG,
priority: TOAST_PRIORITY.HIGH,
...overrides,
});
}
export function createWarning(title: string, description?: string, overrides?: Partial<BusinessNotification>) {
return createMessage("warning", title, description, overrides);
}
export function createInfo(title: string, description?: string, overrides?: Partial<BusinessNotification>) {
return createMessage("info", title, description, overrides);
}
export function createLoading(title: string, description?: string, overrides?: Partial<BusinessNotification>) {
return createMessage("loading", title, description, {
duration: TOAST_DURATIONS.PERSISTENT,
dismissible: false,
...overrides,
});
}
-83
View File
@@ -1,83 +0,0 @@
// src/services/toast/toast.icons.tsx
//
// Animated SVG icons for each toast variant.
// Pure presentational components — no state, no side effects.
// Colors are passed as props (CSS variable strings from VARIANT_TOKENS).
// ─── Success — animated circle + checkmark draw ───────────────────────────────
export function SuccessIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle
className="pim-check-circle"
cx="12" cy="12" r="10"
stroke={color} strokeWidth="2"
/>
<polyline
className="pim-check-mark"
points="7,12.5 10.5,16 17,9"
stroke={color} strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"
fill="none"
/>
</svg>
);
}
// ─── Error — animated circle + X lines draw ───────────────────────────────────
export function ErrorIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle
className="pim-check-circle"
cx="12" cy="12" r="10"
stroke={color} strokeWidth="2"
/>
<line className="pim-x-line" x1="8" y1="8" x2="16" y2="16" stroke={color} strokeWidth="2.2" strokeLinecap="round" />
<line className="pim-x-line" x1="16" y1="8" x2="8" y2="16" stroke={color} strokeWidth="2.2" strokeLinecap="round" />
</svg>
);
}
// ─── Warning — spring-scale triangle ─────────────────────────────────────────
export function WarningIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" className="pim-icon-pop" aria-hidden="true">
<path
d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
stroke={color} strokeWidth="2" strokeLinejoin="round"
/>
<line x1="12" y1="9" x2="12" y2="13" stroke={color} strokeWidth="2.2" strokeLinecap="round" />
<circle cx="12" cy="17" r="1" fill={color} />
</svg>
);
}
// ─── Info — spring-scale circle with dot + line ───────────────────────────────
export function InfoIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" className="pim-icon-pop" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke={color} strokeWidth="2" />
<line x1="12" y1="8" x2="12" y2="8" stroke={color} strokeWidth="2.5" strokeLinecap="round" />
<line x1="12" y1="12" x2="12" y2="16" stroke={color} strokeWidth="2.2" strokeLinecap="round" />
</svg>
);
}
// ─── Loading — continuous rotation spinner ────────────────────────────────────
export function LoadingSpinner({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" className="pim-spinner" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke={color} strokeWidth="2" strokeOpacity="0.2" />
<path
d="M12 2a10 10 0 0110 10"
stroke={color} strokeWidth="2.2" strokeLinecap="round"
/>
</svg>
);
}
-59
View File
@@ -1,59 +0,0 @@
// src/services/toast/toast.internal.ts
//
// Internal low-level toast operations shared between toast.service.ts and
// toast.promise.ts. Not part of the public API — do not export from index.ts.
//
// Exists solely to break the circular dependency:
// toast.service.ts → toast.promise.ts → toast.service.ts
import { toast, type Id } from "react-toastify";
import { SuccessToast, ErrorToast, WarningToast, InfoToast, LoadingToast } from "./components/Toast";
import { DEFAULT_TOAST_OPTIONS } from "./toast.config";
import { loadingToastIds } from "./hooks/useToastRouteCleanup";
import type { ToastContent, ToastOptions, LoadingHandle } from "./toast.types";
function baseOptions(content: ToastContent, options?: ToastOptions) {
return { ...DEFAULT_TOAST_OPTIONS, ...options, data: content };
}
export function showLoading(content: ToastContent, options?: ToastOptions): LoadingHandle {
const id = toast(LoadingToast, {
...baseOptions(content, options),
autoClose: false,
closeOnClick: false,
draggable: false,
});
loadingToastIds.add(id);
return { id };
}
export function updateToast(
id: Id,
variant: "success" | "error" | "warning" | "info",
content: ToastContent,
options?: ToastOptions,
duration?: number | false
): void {
// When a loading toast transitions to a final state, remove it from tracking.
loadingToastIds.delete(id);
const renderers = { success: SuccessToast, error: ErrorToast, warning: WarningToast, info: InfoToast };
// Duration precedence: explicit duration arg → options.autoClose → global default.
// This ensures BusinessNotification.duration (e.g. LONG=7000 for errors) is respected
// when a loading toast transitions to an error state via notify.promise().
const autoClose =
options?.autoClose !== undefined
? options.autoClose
: duration !== undefined
? duration
: DEFAULT_TOAST_OPTIONS.autoClose;
toast.update(id, {
...DEFAULT_TOAST_OPTIONS,
...options,
type: variant,
render: renderers[variant],
data: content,
isLoading: false,
autoClose,
});
}
-78
View File
@@ -1,78 +0,0 @@
// src/services/toast/toast.promise.ts
//
// Owns the Loading → Success → Error promise lifecycle.
// Internal module — not exported from index.ts.
// Exposed only through notify.promise().
import { showLoading, updateToast } from "./toast.internal";
import { normalizeError } from "./toast.errors";
import { dispatchNotification } from "./toast.registry";
import { TOAST_DURATIONS } from "./toast.config";
import type { BusinessNotification, PromiseToastOptions } from "./toast.types";
function toNotification(
payload: { title: string; description?: string },
variant: BusinessNotification["variant"]
): BusinessNotification {
return { title: payload.title, description: payload.description, variant };
}
/**
* Manages the Loading → Success / Error lifecycle using a single toast instance.
* Called internally by notify.promise() — not exported from index.ts.
*/
export async function notifyPromise<T>(
promise: Promise<T> | (() => Promise<T>),
config: PromiseToastOptions<T>
): Promise<T> {
const p = typeof promise === "function" ? promise() : promise;
const loadingNotif = toNotification(config.loading, "loading");
const loadingMsg = dispatchNotification(loadingNotif, config.metadata);
const handle = showLoading(
{ title: loadingMsg.title, description: loadingMsg.description },
config.options
);
try {
const result = await p;
const successRaw =
typeof config.success === "function"
? config.success(result)
: config.success;
const successNotif = toNotification(successRaw, "success");
const successMsg = dispatchNotification(successNotif, config.metadata);
// Use the resolved duration from the notification (MEDIUM=4000 for success).
const successDuration = successMsg.duration ?? TOAST_DURATIONS.MEDIUM;
updateToast(
handle.id,
"success",
{ title: successMsg.title, description: successMsg.description },
config.options,
successDuration
);
return result;
} catch (err) {
const errorRaw = config.error
? (typeof config.error === "function" ? config.error(err) : config.error)
: null;
let errorMsg: BusinessNotification;
if (errorRaw) {
errorMsg = dispatchNotification(toNotification(errorRaw, "error"), config.metadata);
} else {
errorMsg = dispatchNotification(normalizeError(err), config.metadata);
}
// Use the resolved duration from the notification (LONG=7000 for errors).
const errorDuration = errorMsg.duration ?? TOAST_DURATIONS.LONG;
updateToast(
handle.id,
"error",
{ title: errorMsg.title, description: errorMsg.description, httpStatus: errorMsg.httpStatus },
config.options,
errorDuration
);
throw err;
}
}
-36
View File
@@ -1,36 +0,0 @@
// src/services/toast/toast.provider.tsx
//
// Responsible only for:
// - Rendering ToastContainer
// - Registering global configuration
// - Initializing the notification system
// - Cleaning up orphan loading toasts on route change
//
// No business logic. Rendered once in App.tsx (inside the Router).
import { ToastContainer } from "./components/ToastContainer";
import { useToastRouteCleanup } from "./hooks/useToastRouteCleanup";
import "./components/Toast.css";
export function ToastProvider() {
// Dismiss orphan loading toasts when the user navigates to a new route.
useToastRouteCleanup();
return (
<ToastContainer
position="top-right"
autoClose={3000}
closeButton={false}
icon={false}
newestOnTop
pauseOnFocusLoss={false}
pauseOnHover
draggable={false}
limit={5}
className="pim-toast-container"
toastClassName={() => ""}
toastStyle={{ background: "transparent", boxShadow: "none", padding: 0 }}
style={{ width: "auto", minWidth: "340px", maxWidth: "420px" }}
/>
);
}
-76
View File
@@ -1,76 +0,0 @@
// src/services/toast/toast.registry.ts
//
// Notification Dispatcher.
//
// Responsibilities:
// - Receive notification events
// - Attach metadata (timestamp, correlation)
// - Dispatch to all registered handlers
//
// Does NOT store notifications.
// Does NOT implement Notification Center or Header Notifications.
//
// Future subscribers can register here without changing any feature code:
// registry.subscribe(headerNotificationBell)
// registry.subscribe(analyticsTracker)
// registry.subscribe(activityLog)
import type { BusinessNotification, ToastMetadata } from "./toast.types";
export type NotificationHandler = (notification: BusinessNotification) => void;
const handlers: NotificationHandler[] = [];
/**
* Register a handler to receive all dispatched notifications.
* Returns an unsubscribe function.
*
* Duplicate handlers are silently ignored — registering the same function
* reference twice will not cause double-firing.
* Always call the returned unsubscribe in useEffect cleanup to prevent leaks.
*/
export function subscribe(handler: NotificationHandler): () => void {
// Guard: prevent duplicate registrations of the same function reference.
if (handlers.includes(handler)) {
return () => {
const idx = handlers.indexOf(handler);
if (idx !== -1) handlers.splice(idx, 1);
};
}
handlers.push(handler);
return () => {
const idx = handlers.indexOf(handler);
if (idx !== -1) handlers.splice(idx, 1);
};
}
/**
* Enrich a notification with metadata and dispatch it to all registered handlers.
* Returns the enriched notification for the caller (toast renderer) to use.
*/
export function dispatchNotification(
message: BusinessNotification,
metadata?: ToastMetadata
): BusinessNotification {
const enrichedMetadata: ToastMetadata = {
timestamp: Date.now(),
...message.metadata,
...metadata,
};
const enriched: BusinessNotification = { ...message, metadata: enrichedMetadata };
// Dispatch to all registered subscribers.
// Subscriber errors must never break the toast render path.
for (const handler of handlers) {
try {
handler(enriched);
} catch {
// intentionally swallowed
}
}
return enriched;
}
-93
View File
@@ -1,93 +0,0 @@
// src/services/toast/toast.types.ts
export type ToastVariant = "success" | "error" | "warning" | "info" | "loading";
export type ToastPriority = "low" | "normal" | "high" | "critical";
export interface ToastMetadata {
entity?: string;
entityId?: string | number;
feature?: string;
module?: string;
action?: string;
tenantId?: string;
userId?: string;
requestId?: string;
correlationId?: string;
source?: string;
timestamp?: number;
[key: string]: unknown;
}
/**
* Optional action button rendered inside a toast (Retry, Undo, View Details, etc.).
*/
export interface ToastAction {
label: string;
onClick: () => void;
}
/**
* The standard structure of all notifications passed through the system.
*/
export interface BusinessNotification {
title: string;
description?: string;
variant?: ToastVariant;
duration?: number | false;
priority?: ToastPriority;
dismissible?: boolean;
retryable?: boolean;
metadata?: ToastMetadata;
/** HTTP status code preserved from the original error response (e.g. 404, 500). */
httpStatus?: number;
/** Optional action button rendered inside the toast. */
action?: ToastAction;
}
// ToastPayload is an alias for BusinessNotification — kept for named clarity
export type ToastPayload = BusinessNotification;
export type ToastContent = string | ToastPayload;
/**
* Per-call overrides merged on top of global defaults from toast.config.ts.
*
* Use `id` to supply an explicit notification identity.
* The infrastructure maps `id` → react-toastify's `toastId` internally.
* Feature modules must never reference `toastId` directly.
*/
export interface ToastOptions {
/** Explicit notification identity. Takes Priority 1 in identity resolution. */
id?: string | number;
autoClose?: number | false;
/** @internal Kept for backward compatibility. Prefer `id`. */
toastId?: string | number;
closeOnClick?: boolean;
draggable?: boolean;
onClose?: () => void;
onClick?: () => void;
}
/**
* Handle returned by notify.loading() — used to update or dismiss the toast.
* Internal — not exported from index.ts.
*/
export interface LoadingHandle {
id: string | number;
}
// ToastAction is defined above BusinessNotification — kept here as a comment
// so existing imports of ToastAction from this file continue to resolve.
/**
* Public config type for notify.promise().
* Feature modules import this when they need to type their promise config.
*/
export interface PromiseToastOptions<T = unknown> {
loading: { title: string; description?: string };
success: { title: string; description?: string } | ((result: T) => { title: string; description?: string });
error?: { title: string; description?: string } | ((err: unknown) => { title: string; description?: string });
options?: ToastOptions;
metadata?: ToastMetadata;
}
-149
View File
@@ -1,149 +0,0 @@
// src/services/toast/toast.utils.ts
//
// Generic reusable helpers only.
// No business logic. No HTTP parsing. No rendering logic.
import type { ToastVariant, ToastPayload, ToastOptions, ToastMetadata } from "./toast.types";
// ─── Variant token map (used by UI components) ────────────────────────────────
export interface VariantTokens {
accent: string;
accentBg: string;
accentText: string;
label: string;
}
export const VARIANT_TOKENS: Record<ToastVariant, VariantTokens> = {
success: {
accent: "var(--color-success, #10B981)",
accentBg: "color-mix(in srgb, var(--color-success, #10B981) 12%, transparent)",
accentText: "var(--color-success, #10B981)",
label: "Success",
},
error: {
accent: "var(--color-danger, #EF4444)",
accentBg: "color-mix(in srgb, var(--color-danger, #EF4444) 12%, transparent)",
accentText: "var(--color-danger, #EF4444)",
label: "Error",
},
warning: {
accent: "var(--color-warning, #F59E0B)",
accentBg: "color-mix(in srgb, var(--color-warning, #F59E0B) 12%, transparent)",
accentText: "var(--color-warning, #F59E0B)",
label: "Warning",
},
info: {
accent: "var(--color-info, #3B82F6)",
accentBg: "color-mix(in srgb, var(--color-info, #3B82F6) 12%, transparent)",
accentText: "var(--color-info, #3B82F6)",
label: "Info",
},
loading: {
accent: "var(--color-primary, #7C3AED)",
accentBg: "color-mix(in srgb, var(--color-primary, #7C3AED) 12%, transparent)",
accentText: "var(--color-primary, #7C3AED)",
label: "Loading",
},
};
// ─── Generic utilities ────────────────────────────────────────────────────────
export function resolvePayload(
payload: string | ToastPayload,
fallbackLabel: string
): { title: string; description?: string } {
if (typeof payload === "string") {
return { title: payload || fallbackLabel };
}
return { title: payload.title, description: payload.description };
}
// ─── Toast identity ───────────────────────────────────────────────────────────
/**
* djb2 hash — deterministic, zero-dependency, collision-resistant enough for
* toast deduplication. Always produces the same output for the same input.
*/
function djb2(str: string): string {
let h = 5381;
for (let i = 0; i < str.length; i++) {
h = ((h << 5) + h) ^ str.charCodeAt(i);
h = h >>> 0; // keep unsigned 32-bit
}
return h.toString(36);
}
/**
* Produces a deterministic hash from the notification's semantic identity.
* Different entities with the same title (e.g. Product "Not Found" vs
* Category "Not Found") produce different hashes because feature/action differ.
*/
export function createToastHash(
variant: string,
feature: string | undefined,
action: string | undefined,
title: string,
description: string | undefined
): string {
const key = [variant, feature ?? "", action ?? "", title, description ?? ""].join("|");
return `t-${djb2(key)}`;
}
// ─── Suppression window ───────────────────────────────────────────────────────
// Prevents the same notification from firing repeatedly within a short window
// even after the previous toast has already been dismissed.
const SUPPRESSION_WINDOW_MS = 2000;
const recentHashes = new Map<string, number>();
/**
* Returns true if this hash was already shown within the suppression window.
* Records the hash if it was not suppressed.
*/
export function isRecentlySuppressed(
hash: string,
nowMs: number = Date.now()
): boolean {
const last = recentHashes.get(hash);
if (last !== undefined && nowMs - last < SUPPRESSION_WINDOW_MS) {
return true;
}
recentHashes.set(hash, nowMs);
// Prune stale entries to prevent unbounded growth.
for (const [key, ts] of recentHashes) {
if (nowMs - ts >= SUPPRESSION_WINDOW_MS) recentHashes.delete(key);
}
return false;
}
/**
* Resolves the toast identity following the 4-priority chain:
* 1. options.id (public API explicit override — always wins)
* options.toastId (internal backward-compat alias, same priority)
* 2. metadata.correlationId (request lifecycle identity)
* 3. Deterministic hash (variant + feature + action + title + description)
* 4. undefined (let react-toastify assign its own id)
*/
export function resolveToastId(
options: ToastOptions | undefined,
metadata: ToastMetadata | undefined,
variant: string,
title: string,
description: string | undefined
): string | number | undefined {
// Priority 1 — explicit caller identity (public `id` field, or legacy `toastId`)
if (options?.id !== undefined) return options.id;
if (options?.toastId !== undefined) return options.toastId;
// Priority 2 — request lifecycle identity
if (metadata?.correlationId) return metadata.correlationId;
// Priority 3 — deterministic semantic hash
if (title) return createToastHash(variant, metadata?.feature, metadata?.action, title, description);
// Priority 4 — let react-toastify assign its own id
return undefined;
}
/** @deprecated Use resolveToastId instead. Will be removed in a future release. */
export function generateToastId(prefix?: string): string {
return resolveToastId(undefined, undefined, prefix ?? "toast", String(Date.now()), undefined) as string;
}
+56
View File
@@ -0,0 +1,56 @@
// src/services/toast/types/toast.types.ts
//
// Single source of truth for all Toast interfaces, types, and enums.
import type { ToastOptions as ReactToastifyOptions, Id } from "react-toastify";
export type ToastVariant = "success" | "error" | "warning" | "info" | "loading";
export interface ToastAction {
label: string;
onClick: () => void;
}
export interface ToastMetadata {
feature?: string;
action?: string;
timestamp?: number;
correlationId?: string;
}
export interface BusinessNotification {
title: string;
description?: string;
httpStatus?: number;
variant?: ToastVariant;
action?: ToastAction;
duration?: number;
priority?: "low" | "medium" | "high" | "critical";
retryable?: boolean;
dismissible?: boolean;
metadata?: ToastMetadata;
}
export type ToastContent = string | BusinessNotification;
export interface ToastOptions extends Omit<ReactToastifyOptions, "toastId"> {
id?: string;
toastId?: Id;
action?: ToastAction;
metadata?: ToastMetadata;
}
export interface PromiseToastOptions<T = unknown> {
loading: ToastContent;
success: ToastContent | ((data: T) => ToastContent);
error: ToastContent | ((error: unknown) => ToastContent);
options?: ToastOptions;
}
export interface LoadingHandle {
id: Id;
done: (content: ToastContent, options?: ToastOptions) => void;
error: (content: ToastContent | unknown, options?: ToastOptions) => void;
}
export type ToastPayload = BusinessNotification;
+184
View File
@@ -0,0 +1,184 @@
// src/services/toast/utils/toast.utils.ts
//
// Pure helper functions and configuration constants. Zero React / Zero State.
import type { Id } from "react-toastify";
import type { ToastOptions, ToastMetadata, ToastVariant, ToastPayload, BusinessNotification } from "../types/toast.types";
export const DEFAULT_TOAST_OPTIONS = {
autoClose: 6000,
hideProgressBar: true,
closeOnClick: true,
pauseOnHover: true,
draggable: false,
} as const;
export const TOAST_PRIORITY = {
LOW: "low",
MEDIUM: "medium",
HIGH: "high",
CRITICAL: "critical",
} as const;
export const VARIANT_TOKENS: Record<
ToastVariant,
{ label: string; accent: string; accentBg: string; accentText: string }
> = {
success: {
label: "Success",
accent: "#10b981",
accentBg: "#d1fae5",
accentText: "#047857",
},
error: {
label: "Error",
accent: "#ef4444",
accentBg: "#fee2e2",
accentText: "#b91c1c",
},
warning: {
label: "Warning",
accent: "#f59e0b",
accentBg: "#fef3c7",
accentText: "#b45309",
},
info: {
label: "Notice",
accent: "#3b82f6",
accentBg: "#dbeafe",
accentText: "#1d4ed8",
},
loading: {
label: "Loading",
accent: "#6b7280",
accentBg: "#f3f4f6",
accentText: "#374151",
},
};
// ─── Deduplication Window (2 Seconds) ─────────────────────────────────────────
const recentHashes = new Map<string, number>();
export function createToastHash(
variant: string,
feature?: string,
action?: string,
title?: string,
description?: string
): string {
return `${variant}:${feature ?? ""}:${action ?? ""}:${title ?? ""}:${description ?? ""}`;
}
export function isRecentlySuppressed(hash: string, windowMs = 2000): boolean {
const now = Date.now();
const lastTime = recentHashes.get(hash);
if (lastTime && now - lastTime < windowMs) {
return true;
}
recentHashes.set(hash, now);
if (recentHashes.size > 100) {
for (const [h, t] of recentHashes.entries()) {
if (now - t > windowMs * 2) recentHashes.delete(h);
}
}
return false;
}
// ─── Toast ID Resolver ────────────────────────────────────────────────────────
export function resolveToastId(
options?: ToastOptions,
metadata?: ToastMetadata,
variant?: string,
title?: string,
description?: string
): Id | undefined {
if (options?.id !== undefined) return options.id;
if (options?.toastId !== undefined) return options.toastId;
if (metadata?.feature || metadata?.action) {
return `${variant ?? "toast"}-${metadata.feature ?? "gen"}-${metadata.action ?? "act"}`;
}
if (title) {
return createToastHash(variant ?? "toast", undefined, undefined, title, description);
}
return undefined;
}
// ─── Suppressed Errors Filter ──────────────────────────────────────────────────
// Only suppress non-actionable browser noise, NEVER suppress HTTP 404 or 500 errors.
const SUPPRESSED_MESSAGES: string[] = [];
export function isSuppressedError(message: string): boolean {
if (!message) return false;
return SUPPRESSED_MESSAGES.some((suppressed) =>
message.toLowerCase().includes(suppressed.toLowerCase())
);
}
// ─── Error Normalizer ─────────────────────────────────────────────────────────
export function normalizeError(error: unknown): BusinessNotification {
if (typeof error === "string") {
return { title: error, variant: "error" };
}
if (error && typeof error === "object") {
const errObj = error as Record<string, unknown>;
const response = errObj["response"] as Record<string, unknown> | undefined;
const status = (response?.status as number | undefined) || (errObj["status"] as number | undefined);
const data = response?.data as Record<string, unknown> | undefined;
let title =
(data?.message as string) ||
(errObj["message"] as string);
if (!title || title.includes("status code 404") || title.includes("status code 500")) {
if (status === 404) title = "404 Not Found";
else if (status === 500) title = "500 Internal Server Error";
else title = status ? `HTTP Error ${status}` : "An unexpected error occurred";
}
const description = (data?.error as string) || (status ? `Server returned HTTP status code ${status}` : undefined);
return {
title,
description,
httpStatus: status,
variant: "error",
priority: status && status >= 500 ? TOAST_PRIORITY.CRITICAL : TOAST_PRIORITY.HIGH,
};
}
return { title: "An unexpected error occurred", variant: "error" };
}
// ─── Payload Resolver ─────────────────────────────────────────────────────────
export function resolvePayload(
payload: string | ToastPayload,
fallbackLabel: string
): { title: string; description?: string } {
if (typeof payload === "string") {
return { title: payload || fallbackLabel };
}
return {
title: payload.title || fallbackLabel,
description: payload.description,
};
}
// ─── System Messages Constants ───────────────────────────────────────────────
export const systemMessages = {
saveSuccess: "Changes saved successfully",
deleteSuccess: "Item deleted successfully",
createSuccess: "Item created successfully",
updateSuccess: "Item updated successfully",
networkError: "Network connection lost. Please check your network.",
serverError: "Server error occurred. Please try again later.",
};