v2.3 updates in {tosat floder structure and svg for data not found
This commit is contained in:
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
+140
-51
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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.",
|
||||
};
|
||||
Reference in New Issue
Block a user