This commit is contained in:
Flatlogic Bot 2026-05-14 19:14:10 +00:00
parent 05382ed1be
commit 47fbf3afb9
17 changed files with 1180 additions and 1226 deletions

View File

@ -19,7 +19,7 @@ export default function AsideMenu({
<> <>
<AsideMenuLayer <AsideMenuLayer
menu={props.menu} menu={props.menu}
className={`${isAsideMobileExpanded ? 'left-0' : '-left-72 lg:left-0'} ${ className={`${isAsideMobileExpanded ? 'left-0' : '-left-64 lg:left-0'} ${
!isAsideLgActive ? 'lg:hidden xl:flex' : '' !isAsideLgActive ? 'lg:hidden xl:flex' : ''
}`} }`}
onAsideLgCloseClick={props.onAsideLgClose} onAsideLgCloseClick={props.onAsideLgClose}

View File

@ -2,10 +2,8 @@ import React, { useEffect, useState } from 'react'
import { mdiMinus, mdiPlus } from '@mdi/js' import { mdiMinus, mdiPlus } from '@mdi/js'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'
import Link from 'next/link' import Link from 'next/link'
import { getButtonColor } from '../colors'
import AsideMenuList from './AsideMenuList' import AsideMenuList from './AsideMenuList'
import { MenuAsideItem } from '../interfaces' import { MenuAsideItem } from '../interfaces'
import { useAppSelector } from '../stores/hooks'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
type Props = { type Props = {
@ -17,15 +15,6 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
const [isLinkActive, setIsLinkActive] = useState(false) const [isLinkActive, setIsLinkActive] = useState(false)
const [isDropdownActive, setIsDropdownActive] = useState(false) const [isDropdownActive, setIsDropdownActive] = useState(false)
const asideMenuItemStyle = useAppSelector((state) => state.style.asideMenuItemStyle)
const asideMenuDropdownStyle = useAppSelector((state) => state.style.asideMenuDropdownStyle)
const asideMenuItemActiveStyle = useAppSelector((state) => state.style.asideMenuItemActiveStyle)
const borders = useAppSelector((state) => state.style.borders);
const activeLinkColor = useAppSelector(
(state) => state.style.activeLinkColor,
);
const activeClassAddon = !item.color && isLinkActive ? asideMenuItemActiveStyle : ''
const { asPath, isReady } = useRouter() const { asPath, isReady } = useRouter()
useEffect(() => { useEffect(() => {
@ -40,42 +29,45 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
} }
}, [item.href, isReady, asPath]) }, [item.href, isReady, asPath])
const isActiveItem = isLinkActive || (Boolean(item.menu) && isDropdownActive)
const asideMenuItemInnerContents = ( const asideMenuItemInnerContents = (
<> <>
{item.icon && ( {item.icon && (
<BaseIcon path={item.icon} className={`mt-0.5 flex-none ${activeClassAddon}`} size="18" /> <BaseIcon
path={item.icon}
className={`mt-0.5 flex-none ${isActiveItem ? 'text-slate-900 dark:text-white' : 'text-slate-500 dark:text-slate-300'}`}
size="18"
/>
)} )}
<span <span
className={`min-w-0 grow break-words leading-6 ${ className={`min-w-0 grow break-words leading-6 ${
item.menu ? '' : 'pr-2' isActiveItem ? 'text-slate-900 dark:text-white' : 'text-slate-700 dark:text-slate-200'
} ${activeClassAddon}`} }`}
> >
{item.label} {item.label}
</span> </span>
{item.menu && ( {item.menu && (
<BaseIcon <BaseIcon
path={isDropdownActive ? mdiMinus : mdiPlus} path={isDropdownActive ? mdiMinus : mdiPlus}
className={`flex-none ${activeClassAddon}`} className={`flex-none ${isActiveItem ? 'text-slate-900 dark:text-white' : 'text-slate-400 dark:text-slate-400'}`}
w="w-8" size="16"
/> />
)} )}
</> </>
) )
const componentClass = [ const componentClass = [
'flex items-start gap-3 cursor-pointer py-1.5', 'flex cursor-pointer items-start gap-3 rounded-[8px] border transition-colors',
isDropdownList ? 'px-4 text-sm' : '', isDropdownList ? 'px-3 py-2 text-[14px] font-medium' : 'px-3 py-2.5 text-[15px] font-medium',
item.color isActiveItem
? getButtonColor(item.color, false, true) ? 'border-slate-200 bg-white dark:border-dark-600 dark:bg-dark-800'
: `${asideMenuItemStyle}`, : 'border-transparent bg-transparent hover:border-slate-200 hover:bg-slate-50 dark:hover:border-dark-700 dark:hover:bg-dark-800/60',
isLinkActive
? `text-black ${activeLinkColor} dark:text-white dark:bg-dark-800`
: '',
].join(' '); ].join(' ');
return ( return (
<li className={'px-3 py-1.5'}> <li className={isDropdownList ? 'py-0.5' : 'py-1'}>
{item.withDevider && <hr className={`${borders} mb-3`} />} {item.withDevider && <hr className="my-3 border-slate-200 dark:border-dark-700" />}
{item.href && ( {item.href && (
<Link href={item.href} target={item.target} className={componentClass}> <Link href={item.href} target={item.target} className={componentClass}>
{asideMenuItemInnerContents} {asideMenuItemInnerContents}
@ -88,11 +80,11 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
)} )}
{item.menu && ( {item.menu && (
<AsideMenuList <AsideMenuList
menu={item.menu} className={`mt-2 rounded-[10px] border border-slate-200 bg-slate-50/80 p-2 dark:border-dark-700 dark:bg-dark-800/40 ${
className={`${asideMenuDropdownStyle} ${ isDropdownActive ? 'block' : 'hidden'
isDropdownActive ? 'block dark:bg-slate-800/50' : 'hidden'
}`} }`}
isDropdownList isDropdownList
menu={item.menu}
/> />
)} )}
</li> </li>

View File

@ -1,10 +1,9 @@
import React from 'react' import React from 'react'
import { mdiLogout, mdiClose } from '@mdi/js' import { mdiClose } from '@mdi/js'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'
import AsideMenuList from './AsideMenuList' import AsideMenuList from './AsideMenuList'
import { MenuAsideItem } from '../interfaces' import { MenuAsideItem } from '../interfaces'
import { useAppSelector } from '../stores/hooks' import { useAppSelector } from '../stores/hooks'
import Link from 'next/link';
type Props = { type Props = {
@ -14,9 +13,6 @@ type Props = {
} }
export default function AsideMenuLayer({ menu, className = '', ...props }: Props) { export default function AsideMenuLayer({ menu, className = '', ...props }: Props) {
const corners = useAppSelector((state) => state.style.corners);
const asideStyle = useAppSelector((state) => state.style.asideStyle)
const asideBrandStyle = useAppSelector((state) => state.style.asideBrandStyle)
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle) const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle)
const darkMode = useAppSelector((state) => state.style.darkMode) const darkMode = useAppSelector((state) => state.style.darkMode)
@ -29,25 +25,25 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
return ( return (
<aside <aside
id='asideMenu' id='asideMenu'
className={`${className} zzz lg:py-2 lg:pl-2 w-72 fixed flex z-40 top-0 h-screen transition-position overflow-hidden`} className={`${className} fixed top-12 z-40 flex h-[calc(100vh-3rem)] w-64 overflow-hidden px-2 pb-2 transition-position`}
> >
<div <div
className={`flex-1 flex flex-col overflow-hidden dark:bg-dark-900 ${asideStyle} ${corners}`} className="flex flex-1 flex-col overflow-hidden rounded-[10px] border border-slate-200 bg-white shadow-none dark:border-dark-700 dark:bg-dark-900"
> >
<div <div
className={`flex flex-row h-12 items-center justify-between ${asideBrandStyle}`} className="flex h-11 items-center justify-between border-b border-slate-200 px-4 dark:border-dark-700"
> >
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0"> <div className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-semibold tracking-[-0.02em] text-slate-900 dark:text-white">
<b className="font-black">AI Chat Workspace</b> AI Chat Workspace
</span>
</div> </div>
<button <button
className="hidden lg:inline-block xl:hidden p-2.5" className="hidden rounded-[8px] border border-slate-200 bg-white p-2 text-slate-500 lg:inline-flex xl:hidden dark:border-dark-700 dark:bg-dark-900 dark:text-slate-300"
onClick={handleAsideLgCloseClick} onClick={handleAsideLgCloseClick}
type="button"
> >
<BaseIcon path={mdiClose} /> <BaseIcon path={mdiClose} size={16} />
</button> </button>
</div> </div>
<div <div
@ -55,7 +51,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
}`} }`}
> >
<AsideMenuList menu={menu} /> <AsideMenuList className="px-2 py-2.5" menu={menu} />
</div> </div>
</div> </div>
</aside> </aside>

View File

@ -17,17 +17,16 @@ export default function AsideMenuList({ menu, isDropdownList = false, className
return ( return (
<ul className={className}> <ul className={className}>
{menu.map((item, index) => { {menu.map((item) => {
if (!hasPermission(currentUser, item.permissions)) return null; if (!hasPermission(currentUser, item.permissions)) return null;
return ( return (
<div key={index}> <React.Fragment key={item.href || item.label}>
<AsideMenuItem <AsideMenuItem
item={item} item={item}
isDropdownList={isDropdownList} isDropdownList={isDropdownList}
/> />
</div> </React.Fragment>
) )
})} })}
</ul> </ul>

View File

@ -63,7 +63,7 @@ const renderInline = (text: string, keyPrefix: string) =>
return ( return (
<code <code
key={key} key={key}
className="rounded-md border border-slate-200 bg-slate-100 px-1.5 py-0.5 font-mono text-[0.95em] text-slate-700 dark:border-white/10 dark:bg-white/10 dark:text-[#CDE4FF]" className="rounded-[6px] border border-slate-200 bg-slate-100 px-1.5 py-0.5 font-mono text-[0.95em] text-slate-700 dark:border-white/10 dark:bg-white/10 dark:text-[#CDE4FF]"
> >
{token.value} {token.value}
</code> </code>
@ -90,7 +90,7 @@ const renderInline = (text: string, keyPrefix: string) =>
return ( return (
<a <a
key={key} key={key}
className="text-sky-600 underline underline-offset-4 transition hover:text-sky-700 dark:text-[#7DD3FC] dark:hover:text-[#BAE6FD]" className="text-sky-600 underline underline-offset-4 dark:text-[#7DD3FC]"
href={token.href} href={token.href}
rel="noreferrer" rel="noreferrer"
target="_blank" target="_blank"
@ -104,9 +104,9 @@ const renderInline = (text: string, keyPrefix: string) =>
}); });
const headingClassNames: Record<string, string> = { const headingClassNames: Record<string, string> = {
h1: 'text-2xl font-semibold tracking-[-0.03em] text-slate-900 dark:text-white', h1: 'text-[1.35rem] font-semibold tracking-[-0.03em] text-slate-900 dark:text-white',
h2: 'text-xl font-semibold tracking-[-0.02em] text-slate-900 dark:text-white', h2: 'text-[1.15rem] font-semibold tracking-[-0.02em] text-slate-900 dark:text-white',
h3: 'text-lg font-semibold text-slate-900 dark:text-white', h3: 'text-[1rem] font-semibold text-slate-900 dark:text-white',
}; };
const isSpecialBlock = (line: string) => { const isSpecialBlock = (line: string) => {
@ -147,12 +147,12 @@ export default function ChatMarkdown({ content, className = '' }: ChatMarkdownPr
} }
blocks.push( blocks.push(
<div key={`code-${index}`} className="overflow-hidden rounded-2xl border border-slate-200 bg-slate-950 dark:border-white/10 dark:bg-[#050816]"> <div key={`code-${index}`} className="overflow-hidden rounded-[10px] border border-slate-200 bg-slate-950 dark:border-white/10 dark:bg-[#050816]">
<div className="flex items-center justify-between border-b border-slate-800 bg-white/5 px-4 py-2 text-xs uppercase tracking-[0.24em] text-slate-400"> <div className="flex items-center justify-between border-b border-slate-800 bg-white/5 px-4 py-2 text-xs uppercase tracking-[0.24em] text-slate-400">
<span>{language || 'code'}</span> <span>{language || 'code'}</span>
<span>{codeLines.length} lines</span> <span>{codeLines.length} lines</span>
</div> </div>
<pre className="overflow-x-auto px-4 py-4 text-sm leading-6 text-[#E2E8F0]"> <pre className="overflow-x-auto px-4 py-4 text-[13px] leading-6 text-[#E2E8F0]">
<code>{codeLines.join('\n')}</code> <code>{codeLines.join('\n')}</code>
</pre> </pre>
</div>, </div>,
@ -203,7 +203,7 @@ export default function ChatMarkdown({ content, className = '' }: ChatMarkdownPr
blocks.push( blocks.push(
<blockquote <blockquote
key={`quote-${index}`} key={`quote-${index}`}
className="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-900 dark:border-[#38BDF8]/20 dark:bg-[#0F172A] dark:text-[#CFE8FF]" className="rounded-[10px] border border-sky-200 bg-sky-50 px-4 py-3 text-[13px] text-sky-900 dark:border-[#38BDF8]/20 dark:bg-[#0F172A] dark:text-[#CFE8FF]"
> >
{renderInline(quoteLines.join(' '), `quote-${index}`)} {renderInline(quoteLines.join(' '), `quote-${index}`)}
</blockquote>, </blockquote>,
@ -223,7 +223,7 @@ export default function ChatMarkdown({ content, className = '' }: ChatMarkdownPr
} }
blocks.push( blocks.push(
<ul key={`list-${index}`} className="space-y-2 pl-5 text-slate-100"> <ul key={`list-${index}`} className="space-y-1.5 pl-5 text-[13px] text-slate-700 dark:text-slate-100/95">
{items.map((item, itemIndex) => ( {items.map((item, itemIndex) => (
<li key={`item-${index}-${itemIndex}`} className="list-disc"> <li key={`item-${index}-${itemIndex}`} className="list-disc">
{renderInline(item, `list-${index}-${itemIndex}`)} {renderInline(item, `list-${index}-${itemIndex}`)}
@ -241,7 +241,7 @@ export default function ChatMarkdown({ content, className = '' }: ChatMarkdownPr
} }
blocks.push( blocks.push(
<p key={`paragraph-${index}`} className="text-[15px] leading-7 text-slate-700 dark:text-slate-100/95"> <p key={`paragraph-${index}`} className="text-[13px] leading-6 text-slate-700 dark:text-slate-100/95">
{renderInline(paragraphLines.join(' '), `paragraph-${index}`)} {renderInline(paragraphLines.join(' '), `paragraph-${index}`)}
</p>, </p>,
); );

View File

@ -15,6 +15,7 @@ import axios from 'axios';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import BaseIcon from '../BaseIcon'; import BaseIcon from '../BaseIcon';
import { hasPermission } from '../../helpers/userPermissions'; import { hasPermission } from '../../helpers/userPermissions';
import { useAppSelector } from '../../stores/hooks'; import { useAppSelector } from '../../stores/hooks';
@ -196,12 +197,24 @@ const formatMessageTime = (value?: string) => {
return ''; return '';
} }
const date = new Date(value);
const now = new Date();
const isSameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
if (isSameDay) {
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
}).format(date);
}
return new Intl.DateTimeFormat('en-US', { return new Intl.DateTimeFormat('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
hour: 'numeric', }).format(date);
minute: '2-digit',
}).format(new Date(value));
}; };
const getErrorMessage = (error: unknown, fallback: string) => { const getErrorMessage = (error: unknown, fallback: string) => {
@ -222,6 +235,30 @@ const getErrorMessage = (error: unknown, fallback: string) => {
return fallback; return fallback;
}; };
function getAvatarUrl(avatar: any) {
if (!Array.isArray(avatar) || !avatar.length) {
return '';
}
return avatar[0]?.publicUrl || '';
}
function getUserInitial(currentUser: any) {
const firstName = currentUser?.firstName || '';
const lastName = currentUser?.lastName || '';
const initials = `${firstName.slice(0, 1)}${lastName.slice(0, 1)}`.trim();
if (initials) {
return initials.toUpperCase();
}
if (currentUser?.email) {
return currentUser.email.slice(0, 1).toUpperCase();
}
return 'U';
}
const toConversationSummary = (conversation: ConversationDetail): ConversationSummary => ({ const toConversationSummary = (conversation: ConversationDetail): ConversationSummary => ({
id: conversation.id, id: conversation.id,
title: conversation.title, title: conversation.title,
@ -245,6 +282,8 @@ function TypingIndicator() {
} }
type MessageBubbleProps = { type MessageBubbleProps = {
currentUserAvatarUrl: string;
currentUserInitial: string;
currentUserName: string; currentUserName: string;
message: WorkspaceMessage; message: WorkspaceMessage;
streamingText: string; streamingText: string;
@ -252,6 +291,8 @@ type MessageBubbleProps = {
}; };
function MessageBubble({ function MessageBubble({
currentUserAvatarUrl,
currentUserInitial,
currentUserName, currentUserName,
message, message,
streamingText, streamingText,
@ -279,10 +320,10 @@ function MessageBubble({
: 'border border-slate-200 bg-white text-slate-900 shadow-sm'; : 'border border-slate-200 bg-white text-slate-900 shadow-sm';
const avatarLabel = isUser ? currentUserName : 'AI'; const avatarLabel = isUser ? currentUserName : 'AI';
const metaClassName = isUser const metaClassName = isUser
? 'text-[11px] uppercase tracking-[0.18em] text-white/70' ? 'text-[9px] uppercase tracking-[0.12em] text-white/65'
: isFailed : isFailed
? 'text-[11px] uppercase tracking-[0.18em] text-red-500' ? 'text-[9px] uppercase tracking-[0.12em] text-red-500'
: 'text-[11px] uppercase tracking-[0.18em] text-slate-400'; : 'text-[9px] uppercase tracking-[0.12em] text-slate-400';
return ( return (
<div className={`flex gap-2.5 ${isUser ? 'justify-end' : 'justify-start'}`}> <div className={`flex gap-2.5 ${isUser ? 'justify-end' : 'justify-start'}`}>
@ -296,7 +337,7 @@ function MessageBubble({
<span>{isUser ? currentUserName : 'Assistant'}</span> <span>{isUser ? currentUserName : 'Assistant'}</span>
{statusLabel && ( {statusLabel && (
<span <span
className={`rounded-md px-2 py-1 ${ className={`rounded-md px-1.5 py-0.5 text-[9px] ${
isUser isUser
? 'bg-white/10 text-white/80' ? 'bg-white/10 text-white/80'
: isFailed : isFailed
@ -308,7 +349,7 @@ function MessageBubble({
</span> </span>
)} )}
{displayTime && ( {displayTime && (
<span className={isUser ? 'text-white/50' : isFailed ? 'text-red-400' : 'text-slate-400'}> <span className={isUser ? 'text-white/45' : isFailed ? 'text-red-400' : 'text-slate-400'}>
{displayTime} {displayTime}
</span> </span>
)} )}
@ -317,14 +358,14 @@ function MessageBubble({
{message.pending ? ( {message.pending ? (
<TypingIndicator /> <TypingIndicator />
) : isStreaming ? ( ) : isStreaming ? (
<pre className={`whitespace-pre-wrap font-sans text-[13px] leading-5 ${isUser ? 'text-white' : 'text-slate-700'}`}>{streamingText}</pre> <pre className={`whitespace-pre-wrap font-sans text-[12px] leading-5 ${isUser ? 'text-white' : 'text-slate-700'}`}>{streamingText}</pre>
) : ( ) : (
<ChatMarkdown <ChatMarkdown
className={ className={
isUser isUser
? 'text-white [&_a]:text-white [&_a]:decoration-white/60 [&_blockquote]:border-white/20 [&_blockquote]:bg-white/10 [&_blockquote]:text-white [&_code]:border-white/10 [&_code]:bg-white/15 [&_code]:text-white [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_p]:text-white [&_strong]:text-white' ? 'text-white [&_a]:text-white [&_a]:decoration-white/60 [&_blockquote]:border-white/20 [&_blockquote]:bg-white/10 [&_blockquote]:text-white [&_code]:border-white/10 [&_code]:bg-white/15 [&_code]:text-white [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_li]:text-white [&_p]:text-white [&_strong]:text-white [&_ul]:text-white'
: isFailed : isFailed
? 'text-red-900 [&_a]:text-red-700 [&_blockquote]:border-red-200 [&_blockquote]:bg-red-100/70 [&_blockquote]:text-red-900 [&_code]:border-red-200 [&_code]:bg-red-100 [&_code]:text-red-900 [&_h1]:text-red-900 [&_h2]:text-red-900 [&_h3]:text-red-900 [&_p]:text-red-900 [&_strong]:text-red-900' ? 'text-red-900 [&_a]:text-red-700 [&_blockquote]:border-red-200 [&_blockquote]:bg-red-100/70 [&_blockquote]:text-red-900 [&_code]:border-red-200 [&_code]:bg-red-100 [&_code]:text-red-900 [&_h1]:text-red-900 [&_h2]:text-red-900 [&_h3]:text-red-900 [&_li]:text-red-900 [&_p]:text-red-900 [&_strong]:text-red-900 [&_ul]:text-red-900'
: '' : ''
} }
content={rawContent} content={rawContent}
@ -332,9 +373,19 @@ function MessageBubble({
)} )}
</div> </div>
{isUser && ( {isUser && (
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-slate-900 bg-slate-900 text-[10px] font-semibold text-white"> currentUserAvatarUrl ? (
{avatarLabel.slice(0, 1)} <div className="flex h-7 w-7 shrink-0 overflow-hidden rounded-md border border-slate-200 bg-slate-100">
</div> <img
alt={currentUserName}
className="h-full w-full object-cover"
src={currentUserAvatarUrl}
/>
</div>
) : (
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-slate-900 bg-slate-900 text-[10px] font-semibold text-white">
{currentUserInitial}
</div>
)
)} )}
</div> </div>
); );
@ -411,9 +462,18 @@ export default function WorkspaceShell() {
const [streamingText, setStreamingText] = useState(''); const [streamingText, setStreamingText] = useState('');
const [hasBootstrapped, setHasBootstrapped] = useState(false); const [hasBootstrapped, setHasBootstrapped] = useState(false);
const showSuccessToast = useCallback((message: string) => {
toast(message, {
position: 'bottom-center',
type: 'success',
});
}, []);
const currentConversationId = const currentConversationId =
typeof router.query.conversationId === 'string' ? router.query.conversationId : null; typeof router.query.conversationId === 'string' ? router.query.conversationId : null;
const currentUserName = currentUser?.firstName || currentUser?.email || 'You'; const currentUserName = currentUser?.firstName || currentUser?.email || 'You';
const currentUserAvatarUrl = getAvatarUrl(currentUser?.avatar);
const currentUserInitial = getUserInitial(currentUser);
const canAccessAdmin = hasPermission(currentUser, 'READ_USERS'); const canAccessAdmin = hasPermission(currentUser, 'READ_USERS');
const activeConversations = useMemo( const activeConversations = useMemo(
@ -620,17 +680,14 @@ export default function WorkspaceShell() {
}); });
applyConversationPayload(data.conversation); applyConversationPayload(data.conversation);
setIsEditingTitle(false); setIsEditingTitle(false);
setNotice({ showSuccessToast('Conversation renamed.');
type: 'success',
message: 'Conversation renamed.',
});
} catch (error) { } catch (error) {
setNotice({ setNotice({
type: 'error', type: 'error',
message: getErrorMessage(error, 'Failed to rename the conversation.'), message: getErrorMessage(error, 'Failed to rename the conversation.'),
}); });
} }
}, [activeConversation, applyConversationPayload, draftTitle]); }, [activeConversation, applyConversationPayload, draftTitle, showSuccessToast]);
const handleArchiveConversation = useCallback(async () => { const handleArchiveConversation = useCallback(async () => {
if (!activeConversation) { if (!activeConversation) {
@ -647,20 +704,18 @@ export default function WorkspaceShell() {
if (nextStatus === 'archived') { if (nextStatus === 'archived') {
setShowArchived(true); setShowArchived(true);
} }
setNotice({ showSuccessToast(
type: 'success', nextStatus === 'archived'
message: ? 'Conversation archived. It is now listed under Archived chats.'
nextStatus === 'archived' : 'Conversation restored to Recent chats.',
? 'Conversation archived. It is now listed under Archived chats.' );
: 'Conversation restored to Recent chats.',
});
} catch (error) { } catch (error) {
setNotice({ setNotice({
type: 'error', type: 'error',
message: getErrorMessage(error, 'Failed to update the conversation status.'), message: getErrorMessage(error, 'Failed to update the conversation status.'),
}); });
} }
}, [activeConversation, applyConversationPayload]); }, [activeConversation, applyConversationPayload, showSuccessToast]);
const handleDeleteConversation = useCallback(async () => { const handleDeleteConversation = useCallback(async () => {
if (!activeConversation) { if (!activeConversation) {
@ -677,10 +732,7 @@ export default function WorkspaceShell() {
try { try {
await axios.delete(`/workspace/conversations/${activeConversation.id}`); await axios.delete(`/workspace/conversations/${activeConversation.id}`);
removeConversation(activeConversation.id); removeConversation(activeConversation.id);
setNotice({ showSuccessToast('Conversation deleted.');
type: 'success',
message: 'Conversation deleted.',
});
if (nextConversation) { if (nextConversation) {
await router.replace(`/workspace/${nextConversation.id}`); await router.replace(`/workspace/${nextConversation.id}`);
@ -693,7 +745,7 @@ export default function WorkspaceShell() {
message: getErrorMessage(error, 'Failed to delete the conversation.'), message: getErrorMessage(error, 'Failed to delete the conversation.'),
}); });
} }
}, [activeConversation, conversations, removeConversation, router]); }, [activeConversation, conversations, removeConversation, router, showSuccessToast]);
const handleAgentChange = useCallback( const handleAgentChange = useCallback(
async (nextAgentId: string) => { async (nextAgentId: string) => {
@ -900,14 +952,15 @@ export default function WorkspaceShell() {
.find((message) => message.role === 'assistant')?.id || null; .find((message) => message.role === 'assistant')?.id || null;
return ( return (
<section <>
className={`${activeConversation ? 'h-[calc(100vh-3rem)]' : 'min-h-[calc(100vh-3rem)]'} bg-[#f5f5f7] px-0 sm:px-1.5 sm:pb-1.5 sm:pt-1.5`} <section
> className={`${activeConversation ? 'h-[calc(100vh-3rem)]' : 'min-h-[calc(100vh-3rem)]'} bg-[#f5f5f7] px-0 sm:px-1.5 sm:pb-1.5`}
<div
className={`grid border border-slate-200 bg-white shadow-[0_16px_48px_-40px_rgba(15,23,42,0.18)] sm:rounded-[10px] lg:grid-cols-[220px_minmax(0,1fr)] ${
activeConversation ? 'h-full min-h-0 overflow-hidden' : 'min-h-[calc(100vh-3rem)]'
}`}
> >
<div
className={`grid border border-slate-200 bg-white shadow-[0_16px_48px_-40px_rgba(15,23,42,0.18)] sm:rounded-[10px] lg:grid-cols-[220px_minmax(0,1fr)] ${
activeConversation ? 'h-full min-h-0 overflow-hidden' : 'min-h-[calc(100vh-3rem)]'
}`}
>
<aside <aside
className={`absolute inset-y-0 left-0 z-20 flex w-full max-w-[220px] flex-col border-r border-slate-200 bg-[#fafafa] transition duration-200 lg:static lg:translate-x-0 ${ className={`absolute inset-y-0 left-0 z-20 flex w-full max-w-[220px] flex-col border-r border-slate-200 bg-[#fafafa] transition duration-200 lg:static lg:translate-x-0 ${
isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0' isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
@ -1063,13 +1116,13 @@ export default function WorkspaceShell() {
</> </>
)} )}
</div> </div>
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-end gap-1.5">
{activeConversation && !isEditingTitle && ( {activeConversation && !isEditingTitle && (
<> <>
<label className="text-[11px] text-slate-600"> <label className="flex min-w-[176px] flex-col gap-1 text-[11px] text-slate-600">
<span className="mb-1 block text-[9px] uppercase tracking-[0.12em] text-slate-400">Agent</span> <span className="block text-[9px] uppercase tracking-[0.12em] text-slate-400">Agent</span>
<select <select
className="min-w-[156px] bg-white py-1.5 text-[11px]" className="h-10 min-w-[176px] bg-white px-3 py-0 text-[12px]"
onChange={(event) => void handleAgentChange(event.target.value)} onChange={(event) => void handleAgentChange(event.target.value)}
value={agentSelectionValue} value={agentSelectionValue}
> >
@ -1081,14 +1134,14 @@ export default function WorkspaceShell() {
</select> </select>
</label> </label>
<button <button
className="inline-flex h-7 w-7 items-center justify-center rounded-[8px] border border-slate-200 bg-white text-slate-500" className="inline-flex h-10 w-10 items-center justify-center rounded-[8px] border border-slate-200 bg-white text-slate-500"
onClick={() => setIsEditingTitle(true)} onClick={() => setIsEditingTitle(true)}
type="button" type="button"
> >
<BaseIcon path={mdiPencilOutline} size={16} /> <BaseIcon path={mdiPencilOutline} size={16} />
</button> </button>
<button <button
className="inline-flex items-center gap-1 rounded-[8px] border border-slate-200 bg-white px-2.5 py-1.5 text-[11px] font-medium text-slate-700" className="inline-flex h-10 items-center gap-1 rounded-[8px] border border-slate-200 bg-white px-3 text-[12px] font-medium text-slate-700"
onClick={() => void handleArchiveConversation()} onClick={() => void handleArchiveConversation()}
type="button" type="button"
> >
@ -1096,7 +1149,7 @@ export default function WorkspaceShell() {
{activeConversation.status === 'archived' ? 'Restore to recent' : 'Move to archive'} {activeConversation.status === 'archived' ? 'Restore to recent' : 'Move to archive'}
</button> </button>
<button <button
className="inline-flex items-center gap-1 rounded-[8px] border border-red-200 bg-white px-2.5 py-1.5 text-[11px] font-medium text-red-600" className="inline-flex h-10 items-center gap-1 rounded-[8px] border border-red-200 bg-white px-3 text-[12px] font-medium text-red-600"
onClick={() => void handleDeleteConversation()} onClick={() => void handleDeleteConversation()}
type="button" type="button"
> >
@ -1106,7 +1159,7 @@ export default function WorkspaceShell() {
</> </>
)} )}
<button <button
className="inline-flex items-center gap-1.5 rounded-[8px] border border-slate-200 bg-white px-2.5 py-1.5 text-[12px] font-medium text-slate-700 lg:hidden" className="inline-flex h-10 items-center gap-1.5 rounded-[8px] border border-slate-200 bg-white px-3 text-[12px] font-medium text-slate-700 lg:hidden"
onClick={() => setIsSidebarOpen(true)} onClick={() => setIsSidebarOpen(true)}
type="button" type="button"
> >
@ -1114,7 +1167,7 @@ export default function WorkspaceShell() {
History History
</button> </button>
<Link <Link
className="inline-flex items-center gap-1.5 rounded-[8px] border border-slate-200 bg-white px-2.5 py-1.5 text-[12px] font-medium text-slate-700" className="inline-flex h-10 items-center gap-1.5 rounded-[8px] border border-slate-200 bg-white px-3 text-[12px] font-medium text-slate-700"
href="/settings" href="/settings"
> >
<BaseIcon path={mdiCogOutline} size={16} /> <BaseIcon path={mdiCogOutline} size={16} />
@ -1122,7 +1175,7 @@ export default function WorkspaceShell() {
</Link> </Link>
{canAccessAdmin && ( {canAccessAdmin && (
<Link <Link
className="inline-flex items-center gap-1.5 rounded-[8px] border border-slate-200 bg-white px-2.5 py-1.5 text-[12px] font-medium text-slate-700" className="inline-flex h-10 items-center gap-1.5 rounded-[8px] border border-slate-200 bg-white px-3 text-[12px] font-medium text-slate-700"
href="/dashboard" href="/dashboard"
> >
<BaseIcon path={mdiOpenInNew} size={16} /> <BaseIcon path={mdiOpenInNew} size={16} />
@ -1182,6 +1235,8 @@ export default function WorkspaceShell() {
return ( return (
<div className="space-y-1.5" key={message.id}> <div className="space-y-1.5" key={message.id}>
<MessageBubble <MessageBubble
currentUserAvatarUrl={currentUserAvatarUrl}
currentUserInitial={currentUserInitial}
currentUserName={currentUserName} currentUserName={currentUserName}
message={message} message={message}
streamingMessageId={streamingMessageId} streamingMessageId={streamingMessageId}
@ -1235,20 +1290,6 @@ export default function WorkspaceShell() {
<div className="border-t border-slate-200 bg-white px-3.5 py-2 md:px-4"> <div className="border-t border-slate-200 bg-white px-3.5 py-2 md:px-4">
<div className="rounded-[8px] border border-slate-200 bg-white p-2.5"> <div className="rounded-[8px] border border-slate-200 bg-white p-2.5">
<div className="mb-2 flex flex-col gap-1.5 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-[9px] uppercase tracking-[0.14em] text-slate-400">Composer</p>
<p className="mt-0.5 text-[11px] text-slate-500">
{activeConversation?.status === 'archived'
? 'Sending a message will reopen this archived conversation.'
: 'Use Enter to send and Shift+Enter for a new line.'}
</p>
</div>
<div className="rounded-md border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] text-slate-600">
{activeConversation?.agent?.name || emptyStateAgent?.name || 'Workspace assistant'}
</div>
</div>
<div className="grid gap-2 lg:grid-cols-[minmax(0,1fr)_165px]"> <div className="grid gap-2 lg:grid-cols-[minmax(0,1fr)_165px]">
<textarea <textarea
className="min-h-[62px] w-full resize-none rounded-[10px] border border-slate-200 bg-slate-50 px-3 py-2 text-[13px] leading-5 text-slate-900 outline-none placeholder:text-slate-400 focus:border-slate-900" className="min-h-[62px] w-full resize-none rounded-[10px] border border-slate-200 bg-slate-50 px-3 py-2 text-[13px] leading-5 text-slate-900 outline-none placeholder:text-slate-400 focus:border-slate-900"
@ -1291,9 +1332,6 @@ export default function WorkspaceShell() {
{sending ? 'Sending…' : 'Send message'} {sending ? 'Sending…' : 'Send message'}
<BaseIcon path={mdiArrowRight} size={18} /> <BaseIcon path={mdiArrowRight} size={18} />
</button> </button>
<p className="text-[10px] leading-4 text-slate-400">
Saved automatically. Enter sends, Shift+Enter adds a new line.
</p>
</div> </div>
</div> </div>
</div> </div>
@ -1301,26 +1339,26 @@ export default function WorkspaceShell() {
</div> </div>
</> </>
) : ( ) : (
<div className="bg-[#fcfcfd] px-5 py-6 md:px-7 md:py-7"> <div className="bg-[#fcfcfd] px-4 py-4 md:px-5 md:py-5">
<div className="mx-auto flex w-full max-w-4xl flex-col items-start overflow-hidden rounded-[14px] border border-slate-200 bg-white p-5 md:p-6"> <div className="mx-auto flex w-full max-w-[72rem] flex-col items-start overflow-hidden rounded-[12px] border border-slate-200 bg-white p-4 md:p-5">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-100 text-slate-600"> <div className="mb-3 flex h-11 w-11 items-center justify-center rounded-[9px] border border-slate-200 bg-slate-100 text-slate-600">
<BaseIcon path={mdiChatOutline} size={22} /> <BaseIcon path={mdiChatOutline} size={22} />
</div> </div>
<p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400"> <p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
New chat New chat
</p> </p>
<h2 className="mt-2 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900 md:text-[2.25rem]"> <h2 className="mt-2 text-[1.65rem] font-semibold tracking-[-0.04em] text-slate-900 md:text-[1.95rem]">
Start with one clear ask. Start with one clear ask.
</h2> </h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-500"> <p className="mt-2.5 max-w-3xl text-sm leading-6 text-slate-500">
Choose an agent, use a starter tailored to that agent, or type your own prompt Choose an agent, use a starter tailored to that agent, or type your own prompt
below. The first send creates the conversation and adds it to Recent automatically. below. The first send creates the conversation and adds it to Recent automatically.
</p> </p>
<div className="mt-5 grid w-full gap-2.5 md:grid-cols-2"> <div className="mt-4 grid w-full gap-2.5 md:grid-cols-2">
{emptyStateConfig.prompts.map((prompt) => ( {emptyStateConfig.prompts.map((prompt) => (
<button <button
className="rounded-[10px] border border-slate-200 bg-slate-50 px-4 py-3 text-left text-sm leading-6 text-slate-700" className="rounded-[10px] border border-slate-200 bg-slate-50 px-4 py-2.5 text-left text-sm leading-6 text-slate-700"
key={prompt} key={prompt}
onClick={() => { onClick={() => {
setComposer(prompt); setComposer(prompt);
@ -1333,16 +1371,16 @@ export default function WorkspaceShell() {
))} ))}
</div> </div>
<div className="mt-5 grid w-full gap-4 rounded-[12px] border border-slate-200 bg-slate-50 p-4 lg:grid-cols-[minmax(0,1fr)_220px]"> <div className="mt-4 grid w-full gap-3.5 rounded-[12px] border border-slate-200 bg-slate-50 p-3.5 lg:grid-cols-[minmax(0,1fr)_220px]">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[11px] uppercase tracking-[0.24em] text-slate-400">Selected agent</p> <p className="text-[11px] uppercase tracking-[0.24em] text-slate-400">Selected agent</p>
<p className="mt-3 text-base font-medium text-slate-900"> <p className="mt-2.5 text-[15px] font-medium text-slate-900">
{emptyStateAgent?.name || 'Workspace assistant'} {emptyStateAgent?.name || 'Workspace assistant'}
</p> </p>
<p className="mt-1.5 max-w-2xl text-sm leading-6 text-slate-500"> <p className="mt-1 max-w-2xl text-sm leading-6 text-slate-500">
{emptyStateAgent?.description || emptyStateConfig.helper} {emptyStateAgent?.description || emptyStateConfig.helper}
</p> </p>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-2.5 flex flex-wrap gap-2">
{emptyStateConfig.bestFor.map((item) => ( {emptyStateConfig.bestFor.map((item) => (
<span <span
className="inline-flex rounded-[8px] border border-slate-200 bg-white px-2 py-1 text-[10px] text-slate-600" className="inline-flex rounded-[8px] border border-slate-200 bg-white px-2 py-1 text-[10px] text-slate-600"
@ -1354,7 +1392,7 @@ export default function WorkspaceShell() {
</div> </div>
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-2.5">
<label className="text-sm text-slate-600"> <label className="text-sm text-slate-600">
<span className="mb-2 block text-[10px] uppercase tracking-[0.24em] text-slate-400"> <span className="mb-2 block text-[10px] uppercase tracking-[0.24em] text-slate-400">
Start with agent Start with agent
@ -1372,7 +1410,7 @@ export default function WorkspaceShell() {
</select> </select>
</label> </label>
<button <button
className="inline-flex items-center justify-center gap-2 rounded-[10px] bg-slate-900 px-4 py-3 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-60" className="inline-flex items-center justify-center gap-2 rounded-[10px] bg-slate-900 px-4 py-2.5 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-60"
disabled={!composer.trim() || sending || loadingBootstrap} disabled={!composer.trim() || sending || loadingBootstrap}
onClick={() => void handleSendMessage()} onClick={() => void handleSendMessage()}
type="button" type="button"
@ -1383,13 +1421,13 @@ export default function WorkspaceShell() {
</div> </div>
</div> </div>
<div className="mt-4 w-full rounded-[12px] border border-slate-200 bg-slate-50 p-4"> <div className="mt-3.5 w-full rounded-[12px] border border-slate-200 bg-slate-50 p-3.5">
<p className="mb-2 text-[11px] uppercase tracking-[0.24em] text-slate-400">First message</p> <p className="mb-2 text-[11px] uppercase tracking-[0.24em] text-slate-400">First message</p>
<p className="mb-3 text-[12px] leading-5 text-slate-500"> <p className="mb-2.5 text-[12px] leading-5 text-slate-500">
Press Enter to start. Use Shift+Enter for a new line. Press Enter to start. Use Shift+Enter for a new line.
</p> </p>
<textarea <textarea
className="min-h-[120px] w-full resize-none rounded-[10px] border border-slate-200 bg-white px-4 py-3.5 text-[15px] leading-7 text-slate-900 outline-none placeholder:text-slate-400 focus:border-slate-900" className="min-h-[104px] w-full resize-none rounded-[10px] border border-slate-200 bg-white px-4 py-3 text-[14px] leading-6 text-slate-900 outline-none placeholder:text-slate-400 focus:border-slate-900"
onChange={(event) => setComposer(event.target.value)} onChange={(event) => setComposer(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) { if (event.key === 'Enter' && !event.shiftKey) {
@ -1408,6 +1446,7 @@ export default function WorkspaceShell() {
</div> </div>
</div> </div>
</div> </div>
</section> </section>
</>
); );
} }

View File

@ -3,7 +3,7 @@ html {
} }
body { body {
@apply pt-12 xl:pl-72 h-full; @apply pt-12 xl:pl-64 h-full;
} }
#app { #app {

View File

@ -4,7 +4,6 @@ import {
mdiForwardburger, mdiForwardburger,
mdiBackburger, mdiBackburger,
mdiMenu, mdiMenu,
mdiAccountCircleOutline,
mdiChevronDown, mdiChevronDown,
mdiCogOutline, mdiCogOutline,
mdiLogout, mdiLogout,
@ -31,6 +30,30 @@ type Props = {
} }
function getAvatarUrl(avatar: any) {
if (!Array.isArray(avatar) || !avatar.length) {
return '';
}
return avatar[0]?.publicUrl || '';
}
function getUserInitial(currentUser: any) {
const firstName = currentUser?.firstName || '';
const lastName = currentUser?.lastName || '';
const initials = `${firstName.slice(0, 1)}${lastName.slice(0, 1)}`.trim();
if (initials) {
return initials.toUpperCase();
}
if (currentUser?.email) {
return currentUser.email.slice(0, 1).toUpperCase();
}
return 'U';
}
export default function LayoutAuthenticated({ export default function LayoutAuthenticated({
children, children,
@ -79,6 +102,8 @@ export default function LayoutAuthenticated({
const [isAsideLgActive, setIsAsideLgActive] = useState(false) const [isAsideLgActive, setIsAsideLgActive] = useState(false)
const [isWorkspaceAccountMenuOpen, setIsWorkspaceAccountMenuOpen] = useState(false) const [isWorkspaceAccountMenuOpen, setIsWorkspaceAccountMenuOpen] = useState(false)
const workspaceAccountMenuButtonRef = useRef(null) const workspaceAccountMenuButtonRef = useRef(null)
const currentUserAvatarUrl = getAvatarUrl(currentUser?.avatar)
const currentUserInitial = getUserInitial(currentUser)
useEffect(() => { useEffect(() => {
const handleRouteChangeStart = () => { const handleRouteChangeStart = () => {
@ -97,8 +122,8 @@ export default function LayoutAuthenticated({
}, [router.events, dispatch]) }, [router.events, dispatch])
const layoutAsidePadding = isWorkspaceRoute ? '' : 'xl:pl-72' const layoutAsidePadding = isWorkspaceRoute ? '' : 'xl:pl-64'
const layoutOffsetClass = isWorkspaceRoute ? '' : isAsideMobileExpanded ? 'ml-72 lg:ml-0' : '' const layoutOffsetClass = isWorkspaceRoute ? '' : isAsideMobileExpanded ? 'ml-64 lg:ml-0' : ''
return ( return (
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}> <div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
@ -129,7 +154,7 @@ export default function LayoutAuthenticated({
</> </>
)} )}
<Link <Link
className="truncate text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500 dark:text-slate-300" className="truncate text-[14px] font-semibold tracking-[-0.02em] text-slate-700 dark:text-slate-100"
href="/workspace" href="/workspace"
> >
AI Chat Workspace AI Chat Workspace
@ -137,12 +162,24 @@ export default function LayoutAuthenticated({
</div> </div>
<div className="relative"> <div className="relative">
<button <button
className="inline-flex items-center gap-1.5 rounded-[8px] border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-500" className="inline-flex items-center gap-2 rounded-[8px] border border-slate-200 bg-white px-3 py-1.5 text-[12px] font-medium text-slate-700"
onClick={() => setIsWorkspaceAccountMenuOpen((previous) => !previous)} onClick={() => setIsWorkspaceAccountMenuOpen((previous) => !previous)}
ref={workspaceAccountMenuButtonRef} ref={workspaceAccountMenuButtonRef}
type="button" type="button"
> >
<BaseIcon path={mdiAccountCircleOutline} size="18" /> {currentUserAvatarUrl ? (
<span className="flex h-6 w-6 shrink-0 overflow-hidden rounded-full border border-slate-200 bg-slate-100">
<img
alt={currentUser?.email || 'Workspace user'}
className="h-full w-full object-cover"
src={currentUserAvatarUrl}
/>
</span>
) : (
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-slate-100 text-[10px] font-semibold text-slate-600">
{currentUserInitial}
</span>
)}
<span className="hidden max-w-[220px] truncate md:inline"> <span className="hidden max-w-[220px] truncate md:inline">
{currentUser?.email || 'Workspace user'} {currentUser?.email || 'Workspace user'}
</span> </span>

View File

@ -6,6 +6,8 @@ import Head from 'next/head';
import { store } from '../stores/store'; import { store } from '../stores/store';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import '../css/main.css'; import '../css/main.css';
import 'react-toastify/dist/ReactToastify.css';
import { ToastContainer } from 'react-toastify';
import axios from 'axios'; import axios from 'axios';
import { baseURLApi } from '../config'; import { baseURLApi } from '../config';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
@ -185,6 +187,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
<ErrorBoundary> <ErrorBoundary>
<Component {...pageProps} /> <Component {...pageProps} />
</ErrorBoundary> </ErrorBoundary>
<ToastContainer position="bottom-center" />
<IntroGuide <IntroGuide
steps={steps} steps={steps}
stepsName={stepName} stepsName={stepName}

View File

@ -1,87 +1,88 @@
import { mdiChartTimelineVariant } from '@mdi/js' import Head from 'next/head';
import Head from 'next/head' import Link from 'next/link';
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash';
import React, { ReactElement, useState } from 'react' import React, { ReactElement, useState } from 'react';
import CardBox from '../../components/CardBox' import axios from 'axios';
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import TableAgents from '../../components/Agents/TableAgents'
import BaseButton from '../../components/BaseButton'
import axios from "axios";
import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker";
import {setRefetch, uploadCsv} from '../../stores/agents/agentsSlice';
import CardBox from '../../components/CardBox';
import CardBoxModal from '../../components/CardBoxModal';
import DragDropFilePicker from '../../components/DragDropFilePicker';
import TableAgents from '../../components/Agents/TableAgents';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import { hasPermission } from '../../helpers/userPermissions';
import LayoutAuthenticated from '../../layouts/Authenticated';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { setRefetch, uploadCsv } from '../../stores/agents/agentsSlice';
import {hasPermission} from "../../helpers/userPermissions"; const primaryActionClassName =
'inline-flex items-center justify-center rounded-[8px] bg-slate-900 px-4 py-2 text-sm font-medium text-white';
const secondaryActionClassName =
'inline-flex items-center justify-center rounded-[8px] border border-slate-200 bg-white px-3.5 py-2 text-sm font-medium text-slate-700';
const AgentsTablesPage = () => { const AgentsTablesPage = () => {
const [filterItems, setFilterItems] = useState([]); const [filterItems, setFilterItems] = useState([]);
const [csvFile, setCsvFile] = useState<File | null>(null); const [csvFile, setCsvFile] = useState<File | null>(null);
const [isModalActive, setIsModalActive] = useState(false); const [isModalActive, setIsModalActive] = useState(false);
const [showTableView, setShowTableView] = useState(false);
const { currentUser } = useAppSelector((state) => state.auth); const { currentUser } = useAppSelector((state) => state.auth);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [filters] = useState([
const [filters] = useState([{label: 'Name', title: 'name'},{label: 'Description', title: 'description'},{label: 'Model', title: 'model'},{label: 'Systemprompt', title: 'system_prompt'},{label: 'MetadataJSON', title: 'metadata_json'}, { label: 'Name', title: 'name' },
{label: 'Maxoutputtokens', title: 'max_output_tokens', number: 'true'}, { label: 'Description', title: 'description' },
{label: 'Temperature', title: 'temperature', number: 'true'}, { label: 'Model', title: 'model' },
{ label: 'Systemprompt', title: 'system_prompt' },
{ label: 'MetadataJSON', title: 'metadata_json' },
{ label: 'Maxoutputtokens', title: 'max_output_tokens', number: 'true' },
{ label: 'Temperature', title: 'temperature', number: 'true' },
]); ]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_AGENTS');
const addFilter = () => { const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_AGENTS');
const newItem = {
id: uniqueId(), const addFilter = () => {
fields: { const newItem = {
filterValue: '', id: uniqueId(),
filterValueFrom: '', fields: {
filterValueTo: '', filterValue: '',
selectedField: '', filterValueFrom: '',
}, filterValueTo: '',
}; selectedField: filters[0].title,
newItem.fields.selectedField = filters[0].title; },
setFilterItems([...filterItems, newItem]);
}; };
const getAgentsCSV = async () => { setFilterItems([...filterItems, newItem]);
const response = await axios({url: '/agents?filetype=csv', method: 'GET',responseType: 'blob'}); };
const type = response.headers['content-type']
const blob = new Blob([response.data], { type: type })
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = 'agentsCSV.csv'
link.click()
};
const onModalConfirm = async () => { const getAgentsCSV = async () => {
if (!csvFile) return; const response = await axios({
await dispatch(uploadCsv(csvFile)); url: '/agents?filetype=csv',
dispatch(setRefetch(true)); method: 'GET',
setCsvFile(null); responseType: 'blob',
setIsModalActive(false); });
}; const type = response.headers['content-type'];
const blob = new Blob([response.data], { type });
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = 'agentsCSV.csv';
link.click();
};
const onModalCancel = () => { const onModalConfirm = async () => {
setCsvFile(null); if (!csvFile) {
setIsModalActive(false); return;
}; }
await dispatch(uploadCsv(csvFile));
dispatch(setRefetch(true));
setCsvFile(null);
setIsModalActive(false);
};
const onModalCancel = () => {
setCsvFile(null);
setIsModalActive(false);
};
return ( return (
<> <>
@ -89,74 +90,77 @@ const AgentsTablesPage = () => {
<title>{getPageTitle('Agents')}</title> <title>{getPageTitle('Agents')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Agents" main> <div className="mb-5 rounded-[10px] border border-slate-200 bg-white px-5 py-5">
{''} <div className="flex flex-wrap items-start justify-between gap-4">
</SectionTitleLineWithButton> <div className="min-w-0">
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'> <p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
Admin
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/agents/agents-new'} color='info' label='New Item'/>} </p>
<h1 className="mt-2 text-[1.65rem] font-semibold tracking-[-0.04em] text-slate-900">
<BaseButton Agents
className={'mr-3'} </h1>
color='info' <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
label='Filter' Manage assistant presets, models, and prompt behavior used across the workspace.
onClick={addFilter} </p>
/> </div>
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getAgentsCSV} />
{hasCreatePermission && ( {hasCreatePermission && (
<BaseButton <Link className={primaryActionClassName} href="/agents/agents-new">
color='info' New agent
label='Upload CSV' </Link>
onClick={() => setIsModalActive(true)}
/>
)} )}
</div>
<div className='md:inline-flex items-center ms-auto'> </div>
<div id='delete-rows-button'></div>
</div> <div className="mb-4 flex flex-wrap items-center gap-2 rounded-[10px] border border-slate-200 bg-slate-50 px-3.5 py-3">
<button className={secondaryActionClassName} onClick={addFilter} type="button">
</CardBox> Add filter
</button>
<CardBox className="mb-6" hasTable> <button
className={secondaryActionClassName}
onClick={() => void getAgentsCSV()}
type="button"
>
Download CSV
</button>
{hasCreatePermission && (
<button
className={secondaryActionClassName}
onClick={() => setIsModalActive(true)}
type="button"
>
Upload CSV
</button>
)}
<div className="ml-auto flex items-center">
<div id="delete-rows-button" />
</div>
</div>
<CardBox className="mb-6 rounded-[10px] border border-slate-200 bg-white shadow-none" hasTable>
<TableAgents <TableAgents
filterItems={filterItems} filterItems={filterItems}
setFilterItems={setFilterItems}
filters={filters} filters={filters}
setFilterItems={setFilterItems}
showGrid={false} showGrid={false}
/> />
</CardBox> </CardBox>
</SectionMain> </SectionMain>
<CardBoxModal <CardBoxModal
title='Upload CSV' buttonColor="info"
buttonColor='info' buttonLabel="Confirm"
buttonLabel={'Confirm'} isActive={isModalActive}
// buttonLabel={false ? 'Deleting...' : 'Confirm'} onCancel={onModalCancel}
isActive={isModalActive} onConfirm={onModalConfirm}
onConfirm={onModalConfirm} title="Upload CSV"
onCancel={onModalCancel}
> >
<DragDropFilePicker <DragDropFilePicker file={csvFile} formats=".csv" setFile={setCsvFile} />
file={csvFile}
setFile={setCsvFile}
formats={'.csv'}
/>
</CardBoxModal> </CardBoxModal>
</> </>
) );
} };
AgentsTablesPage.getLayout = function getLayout(page: ReactElement) { AgentsTablesPage.getLayout = function getLayout(page: ReactElement) {
return ( return <LayoutAuthenticated permission="READ_AGENTS">{page}</LayoutAuthenticated>;
<LayoutAuthenticated };
permission={'READ_AGENTS'}
>
{page}
</LayoutAuthenticated>
)
}
export default AgentsTablesPage export default AgentsTablesPage;

View File

@ -1,95 +1,57 @@
import { mdiChartTimelineVariant } from '@mdi/js' import Head from 'next/head';
import Head from 'next/head'
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash';
import React, { ReactElement, useState } from 'react' import React, { ReactElement, useState } from 'react';
import CardBox from '../../components/CardBox' import axios from 'axios';
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import TableConversations from '../../components/Conversations/TableConversations'
import BaseButton from '../../components/BaseButton'
import axios from "axios";
import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker";
import {setRefetch, uploadCsv} from '../../stores/conversations/conversationsSlice';
import {hasPermission} from "../../helpers/userPermissions";
import CardBox from '../../components/CardBox';
import TableConversations from '../../components/Conversations/TableConversations';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import LayoutAuthenticated from '../../layouts/Authenticated';
const secondaryActionClassName =
'inline-flex items-center justify-center rounded-[8px] border border-slate-200 bg-white px-3.5 py-2 text-sm font-medium text-slate-700';
const ConversationsTablesPage = () => { const ConversationsTablesPage = () => {
const [filterItems, setFilterItems] = useState([]); const [filterItems, setFilterItems] = useState([]);
const [csvFile, setCsvFile] = useState<File | null>(null);
const [isModalActive, setIsModalActive] = useState(false);
const [showTableView, setShowTableView] = useState(false);
const [filters] = useState([
const { currentUser } = useAppSelector((state) => state.auth); { label: 'Title', title: 'title' },
{ label: 'Summary', title: 'summary' },
{ label: 'ClientcontextJSON', title: 'client_context_json' },
const dispatch = useAppDispatch(); { label: 'Lastmessageat', title: 'last_message_at', date: 'true' },
{ label: 'User', title: 'user' },
{ label: 'Agent', title: 'agent' },
const [filters] = useState([{label: 'Title', title: 'title'},{label: 'Summary', title: 'summary'},{label: 'ClientcontextJSON', title: 'client_context_json'}, { label: 'Status', title: 'status', type: 'enum', options: ['active', 'archived', 'deleted'] },
{label: 'Lastmessageat', title: 'last_message_at', date: 'true'},
{label: 'User', title: 'user'},
{label: 'Agent', title: 'agent'},
{label: 'Status', title: 'status', type: 'enum', options: ['active','archived','deleted']},
]); ]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_CONVERSATIONS');
const addFilter = () => { const addFilter = () => {
const newItem = { const newItem = {
id: uniqueId(), id: uniqueId(),
fields: { fields: {
filterValue: '', filterValue: '',
filterValueFrom: '', filterValueFrom: '',
filterValueTo: '', filterValueTo: '',
selectedField: '', selectedField: filters[0].title,
}, },
};
newItem.fields.selectedField = filters[0].title;
setFilterItems([...filterItems, newItem]);
}; };
const getConversationsCSV = async () => { setFilterItems([...filterItems, newItem]);
const response = await axios({url: '/conversations?filetype=csv', method: 'GET',responseType: 'blob'}); };
const type = response.headers['content-type']
const blob = new Blob([response.data], { type: type })
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = 'conversationsCSV.csv'
link.click()
};
const onModalConfirm = async () => { const getConversationsCSV = async () => {
if (!csvFile) return; const response = await axios({
await dispatch(uploadCsv(csvFile)); url: '/conversations?filetype=csv',
dispatch(setRefetch(true)); method: 'GET',
setCsvFile(null); responseType: 'blob',
setIsModalActive(false); });
}; const type = response.headers['content-type'];
const blob = new Blob([response.data], { type });
const onModalCancel = () => { const link = document.createElement('a');
setCsvFile(null); link.href = window.URL.createObjectURL(blob);
setIsModalActive(false); link.download = 'conversationsCSV.csv';
}; link.click();
};
return ( return (
<> <>
@ -97,74 +59,47 @@ const ConversationsTablesPage = () => {
<title>{getPageTitle('Conversations')}</title> <title>{getPageTitle('Conversations')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Conversations" main> <div className="mb-5 rounded-[10px] border border-slate-200 bg-white px-5 py-5">
{''} <p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">Admin</p>
</SectionTitleLineWithButton> <h1 className="mt-2 text-[1.65rem] font-semibold tracking-[-0.04em] text-slate-900">
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'> Conversations
</h1>
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/conversations/conversations-new'} color='info' label='New Item'/>} <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
Review active and archived threads, inspect ownership, and follow how the workspace is being used.
<BaseButton </p>
className={'mr-3'} </div>
color='info'
label='Filter' <div className="mb-4 flex flex-wrap items-center gap-2 rounded-[10px] border border-slate-200 bg-slate-50 px-3.5 py-3">
onClick={addFilter} <button className={secondaryActionClassName} onClick={addFilter} type="button">
/> Add filter
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getConversationsCSV} /> </button>
<button
{hasCreatePermission && ( className={secondaryActionClassName}
<BaseButton onClick={() => void getConversationsCSV()}
color='info' type="button"
label='Upload CSV' >
onClick={() => setIsModalActive(true)} Download CSV
/> </button>
)} <div className="ml-auto flex items-center">
<div id="delete-rows-button" />
<div className='md:inline-flex items-center ms-auto'> </div>
<div id='delete-rows-button'></div> </div>
</div>
<CardBox className="mb-6 rounded-[10px] border border-slate-200 bg-white shadow-none" hasTable>
</CardBox>
<CardBox className="mb-6" hasTable>
<TableConversations <TableConversations
filterItems={filterItems} filterItems={filterItems}
setFilterItems={setFilterItems}
filters={filters} filters={filters}
setFilterItems={setFilterItems}
showGrid={false} showGrid={false}
/>
</CardBox>
</SectionMain>
<CardBoxModal
title='Upload CSV'
buttonColor='info'
buttonLabel={'Confirm'}
// buttonLabel={false ? 'Deleting...' : 'Confirm'}
isActive={isModalActive}
onConfirm={onModalConfirm}
onCancel={onModalCancel}
>
<DragDropFilePicker
file={csvFile}
setFile={setCsvFile}
formats={'.csv'}
/> />
</CardBoxModal> </CardBox>
</SectionMain>
</> </>
) );
} };
ConversationsTablesPage.getLayout = function getLayout(page: ReactElement) { ConversationsTablesPage.getLayout = function getLayout(page: ReactElement) {
return ( return <LayoutAuthenticated permission="READ_CONVERSATIONS">{page}</LayoutAuthenticated>;
<LayoutAuthenticated };
permission={'READ_CONVERSATIONS'}
>
{page}
</LayoutAuthenticated>
)
}
export default ConversationsTablesPage export default ConversationsTablesPage;

View File

@ -1,95 +1,67 @@
import { mdiChartTimelineVariant } from '@mdi/js' import Head from 'next/head';
import Head from 'next/head'
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash';
import React, { ReactElement, useState } from 'react' import React, { ReactElement, useState } from 'react';
import CardBox from '../../components/CardBox' import axios from 'axios';
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import TableMessages from '../../components/Messages/TableMessages'
import BaseButton from '../../components/BaseButton'
import axios from "axios";
import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker";
import {setRefetch, uploadCsv} from '../../stores/messages/messagesSlice';
import {hasPermission} from "../../helpers/userPermissions";
import CardBox from '../../components/CardBox';
import TableMessages from '../../components/Messages/TableMessages';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import LayoutAuthenticated from '../../layouts/Authenticated';
const secondaryActionClassName =
'inline-flex items-center justify-center rounded-[8px] border border-slate-200 bg-white px-3.5 py-2 text-sm font-medium text-slate-700';
const MessagesTablesPage = () => { const MessagesTablesPage = () => {
const [filterItems, setFilterItems] = useState([]); const [filterItems, setFilterItems] = useState([]);
const [csvFile, setCsvFile] = useState<File | null>(null);
const [isModalActive, setIsModalActive] = useState(false);
const [showTableView, setShowTableView] = useState(false);
const [filters] = useState([
const { currentUser } = useAppSelector((state) => state.auth); { label: 'Content', title: 'content' },
{ label: 'Contentmarkdown', title: 'content_markdown' },
{ label: 'Toolname', title: 'tool_name' },
const dispatch = useAppDispatch(); { label: 'ToolcallJSON', title: 'tool_call_json' },
{ label: 'ToolresultJSON', title: 'tool_result_json' },
{ label: 'Sequence', title: 'sequence', number: 'true' },
const [filters] = useState([{label: 'Content', title: 'content'},{label: 'Contentmarkdown', title: 'content_markdown'},{label: 'Toolname', title: 'tool_name'},{label: 'ToolcallJSON', title: 'tool_call_json'},{label: 'ToolresultJSON', title: 'tool_result_json'}, { label: 'Sentat', title: 'sent_at', date: 'true' },
{label: 'Sequence', title: 'sequence', number: 'true'}, { label: 'Completedat', title: 'completed_at', date: 'true' },
{ label: 'Conversation', title: 'conversation' },
{label: 'Sentat', title: 'sent_at', date: 'true'},{label: 'Completedat', title: 'completed_at', date: 'true'}, { label: 'Authoruser', title: 'author_user' },
{ label: 'Role', title: 'role', type: 'enum', options: ['user', 'assistant', 'system', 'tool'] },
{
{label: 'Conversation', title: 'conversation'}, label: 'Deliverystatus',
title: 'delivery_status',
type: 'enum',
options: ['draft', 'sent', 'streaming', 'completed', 'failed'],
{label: 'Authoruser', title: 'author_user'}, },
{label: 'Role', title: 'role', type: 'enum', options: ['user','assistant','system','tool']},{label: 'Deliverystatus', title: 'delivery_status', type: 'enum', options: ['draft','sent','streaming','completed','failed']},
]); ]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_MESSAGES');
const addFilter = () => { const addFilter = () => {
const newItem = { const newItem = {
id: uniqueId(), id: uniqueId(),
fields: { fields: {
filterValue: '', filterValue: '',
filterValueFrom: '', filterValueFrom: '',
filterValueTo: '', filterValueTo: '',
selectedField: '', selectedField: filters[0].title,
}, },
};
newItem.fields.selectedField = filters[0].title;
setFilterItems([...filterItems, newItem]);
}; };
const getMessagesCSV = async () => { setFilterItems([...filterItems, newItem]);
const response = await axios({url: '/messages?filetype=csv', method: 'GET',responseType: 'blob'}); };
const type = response.headers['content-type']
const blob = new Blob([response.data], { type: type })
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = 'messagesCSV.csv'
link.click()
};
const onModalConfirm = async () => { const getMessagesCSV = async () => {
if (!csvFile) return; const response = await axios({
await dispatch(uploadCsv(csvFile)); url: '/messages?filetype=csv',
dispatch(setRefetch(true)); method: 'GET',
setCsvFile(null); responseType: 'blob',
setIsModalActive(false); });
}; const type = response.headers['content-type'];
const blob = new Blob([response.data], { type });
const onModalCancel = () => { const link = document.createElement('a');
setCsvFile(null); link.href = window.URL.createObjectURL(blob);
setIsModalActive(false); link.download = 'messagesCSV.csv';
}; link.click();
};
return ( return (
<> <>
@ -97,74 +69,47 @@ const MessagesTablesPage = () => {
<title>{getPageTitle('Messages')}</title> <title>{getPageTitle('Messages')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Messages" main> <div className="mb-5 rounded-[10px] border border-slate-200 bg-white px-5 py-5">
{''} <p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">Admin</p>
</SectionTitleLineWithButton> <h1 className="mt-2 text-[1.65rem] font-semibold tracking-[-0.04em] text-slate-900">
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'> Messages
</h1>
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/messages/messages-new'} color='info' label='New Item'/>} <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
Inspect prompts, responses, delivery states, and the raw message flow inside each conversation.
<BaseButton </p>
className={'mr-3'} </div>
color='info'
label='Filter' <div className="mb-4 flex flex-wrap items-center gap-2 rounded-[10px] border border-slate-200 bg-slate-50 px-3.5 py-3">
onClick={addFilter} <button className={secondaryActionClassName} onClick={addFilter} type="button">
/> Add filter
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getMessagesCSV} /> </button>
<button
{hasCreatePermission && ( className={secondaryActionClassName}
<BaseButton onClick={() => void getMessagesCSV()}
color='info' type="button"
label='Upload CSV' >
onClick={() => setIsModalActive(true)} Download CSV
/> </button>
)} <div className="ml-auto flex items-center">
<div id="delete-rows-button" />
<div className='md:inline-flex items-center ms-auto'> </div>
<div id='delete-rows-button'></div> </div>
</div>
<CardBox className="mb-6 rounded-[10px] border border-slate-200 bg-white shadow-none" hasTable>
</CardBox>
<CardBox className="mb-6" hasTable>
<TableMessages <TableMessages
filterItems={filterItems} filterItems={filterItems}
setFilterItems={setFilterItems}
filters={filters} filters={filters}
setFilterItems={setFilterItems}
showGrid={false} showGrid={false}
/>
</CardBox>
</SectionMain>
<CardBoxModal
title='Upload CSV'
buttonColor='info'
buttonLabel={'Confirm'}
// buttonLabel={false ? 'Deleting...' : 'Confirm'}
isActive={isModalActive}
onConfirm={onModalConfirm}
onCancel={onModalCancel}
>
<DragDropFilePicker
file={csvFile}
setFile={setCsvFile}
formats={'.csv'}
/> />
</CardBoxModal> </CardBox>
</SectionMain>
</> </>
) );
} };
MessagesTablesPage.getLayout = function getLayout(page: ReactElement) { MessagesTablesPage.getLayout = function getLayout(page: ReactElement) {
return ( return <LayoutAuthenticated permission="READ_MESSAGES">{page}</LayoutAuthenticated>;
<LayoutAuthenticated };
permission={'READ_MESSAGES'}
>
{page}
</LayoutAuthenticated>
)
}
export default MessagesTablesPage export default MessagesTablesPage;

View File

@ -1,87 +1,49 @@
import { mdiChartTimelineVariant } from '@mdi/js' import Head from 'next/head';
import Head from 'next/head'
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash';
import React, { ReactElement, useState } from 'react' import React, { ReactElement, useState } from 'react';
import CardBox from '../../components/CardBox' import axios from 'axios';
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import TablePermissions from '../../components/Permissions/TablePermissions'
import BaseButton from '../../components/BaseButton'
import axios from "axios";
import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker";
import {setRefetch, uploadCsv} from '../../stores/permissions/permissionsSlice';
import {hasPermission} from "../../helpers/userPermissions";
import CardBox from '../../components/CardBox';
import TablePermissions from '../../components/Permissions/TablePermissions';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import LayoutAuthenticated from '../../layouts/Authenticated';
const secondaryActionClassName =
'inline-flex items-center justify-center rounded-[8px] border border-slate-200 bg-white px-3.5 py-2 text-sm font-medium text-slate-700';
const PermissionsTablesPage = () => { const PermissionsTablesPage = () => {
const [filterItems, setFilterItems] = useState([]); const [filterItems, setFilterItems] = useState([]);
const [csvFile, setCsvFile] = useState<File | null>(null);
const [isModalActive, setIsModalActive] = useState(false);
const [showTableView, setShowTableView] = useState(false);
const [filters] = useState([{ label: 'Name', title: 'name' }]);
const { currentUser } = useAppSelector((state) => state.auth);
const dispatch = useAppDispatch(); const addFilter = () => {
const newItem = {
id: uniqueId(),
const [filters] = useState([{label: 'Name', title: 'name'}, fields: {
filterValue: '',
filterValueFrom: '',
filterValueTo: '',
selectedField: filters[0].title,
},
]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_PERMISSIONS');
const addFilter = () => {
const newItem = {
id: uniqueId(),
fields: {
filterValue: '',
filterValueFrom: '',
filterValueTo: '',
selectedField: '',
},
};
newItem.fields.selectedField = filters[0].title;
setFilterItems([...filterItems, newItem]);
}; };
const getPermissionsCSV = async () => { setFilterItems([...filterItems, newItem]);
const response = await axios({url: '/permissions?filetype=csv', method: 'GET',responseType: 'blob'}); };
const type = response.headers['content-type']
const blob = new Blob([response.data], { type: type })
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = 'permissionsCSV.csv'
link.click()
};
const onModalConfirm = async () => { const getPermissionsCSV = async () => {
if (!csvFile) return; const response = await axios({
await dispatch(uploadCsv(csvFile)); url: '/permissions?filetype=csv',
dispatch(setRefetch(true)); method: 'GET',
setCsvFile(null); responseType: 'blob',
setIsModalActive(false); });
}; const type = response.headers['content-type'];
const blob = new Blob([response.data], { type });
const onModalCancel = () => { const link = document.createElement('a');
setCsvFile(null); link.href = window.URL.createObjectURL(blob);
setIsModalActive(false); link.download = 'permissionsCSV.csv';
}; link.click();
};
return ( return (
<> <>
@ -89,74 +51,49 @@ const PermissionsTablesPage = () => {
<title>{getPageTitle('Permissions')}</title> <title>{getPageTitle('Permissions')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Permissions" main> <div className="mb-5 rounded-[10px] border border-slate-200 bg-white px-5 py-5">
{''} <p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
</SectionTitleLineWithButton> Access control
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'> </p>
<h1 className="mt-2 text-[1.65rem] font-semibold tracking-[-0.04em] text-slate-900">
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/permissions/permissions-new'} color='info' label='New Item'/>} Permissions
</h1>
<BaseButton <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
className={'mr-3'} Inspect the low-level capability map that powers admin access throughout the app.
color='info' </p>
label='Filter' </div>
onClick={addFilter}
/> <div className="mb-4 flex flex-wrap items-center gap-2 rounded-[10px] border border-slate-200 bg-slate-50 px-3.5 py-3">
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getPermissionsCSV} /> <button className={secondaryActionClassName} onClick={addFilter} type="button">
Add filter
{hasCreatePermission && ( </button>
<BaseButton <button
color='info' className={secondaryActionClassName}
label='Upload CSV' onClick={() => void getPermissionsCSV()}
onClick={() => setIsModalActive(true)} type="button"
/> >
)} Download CSV
</button>
<div className='md:inline-flex items-center ms-auto'> <div className="ml-auto flex items-center">
<div id='delete-rows-button'></div> <div id="delete-rows-button" />
</div> </div>
</div>
</CardBox>
<CardBox className="mb-6 rounded-[10px] border border-slate-200 bg-white shadow-none" hasTable>
<CardBox className="mb-6" hasTable>
<TablePermissions <TablePermissions
filterItems={filterItems} filterItems={filterItems}
setFilterItems={setFilterItems}
filters={filters} filters={filters}
setFilterItems={setFilterItems}
showGrid={false} showGrid={false}
/>
</CardBox>
</SectionMain>
<CardBoxModal
title='Upload CSV'
buttonColor='info'
buttonLabel={'Confirm'}
// buttonLabel={false ? 'Deleting...' : 'Confirm'}
isActive={isModalActive}
onConfirm={onModalConfirm}
onCancel={onModalCancel}
>
<DragDropFilePicker
file={csvFile}
setFile={setCsvFile}
formats={'.csv'}
/> />
</CardBoxModal> </CardBox>
</SectionMain>
</> </>
) );
} };
PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) { PermissionsTablesPage.getLayout = function getLayout(page: ReactElement) {
return ( return <LayoutAuthenticated permission="READ_PERMISSIONS">{page}</LayoutAuthenticated>;
<LayoutAuthenticated };
permission={'READ_PERMISSIONS'}
>
{page}
</LayoutAuthenticated>
)
}
export default PermissionsTablesPage export default PermissionsTablesPage;

View File

@ -1,153 +1,325 @@
import { import {
mdiChartTimelineVariant, mdiAccountCircleOutline,
mdiUpload, mdiArrowLeft,
mdiCogOutline,
mdiLockOutline,
mdiPhoneOutline,
mdiUpload,
} from '@mdi/js'; } from '@mdi/js';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement, useEffect, useState } from 'react'; import React, { ReactElement, useEffect, useState } from 'react';
import { Field, Form, Formik } from 'formik';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import CardBox from '../components/CardBox'; import BaseIcon from '../components/BaseIcon';
import LayoutAuthenticated from '../layouts/Authenticated';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config';
import { Field, Form, Formik } from 'formik';
import FormField from '../components/FormField';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import BaseButton from '../components/BaseButton';
import FormImagePicker from '../components/FormImagePicker'; import FormImagePicker from '../components/FormImagePicker';
import SectionMain from '../components/SectionMain';
import { update } from '../stores/users/usersSlice'; import { getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated';
import { findMe } from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router'; import { update } from '../stores/users/usersSlice';
import {findMe} from "../stores/authSlice";
const inputClassName =
'w-full rounded-[10px] border border-slate-200 bg-white px-3.5 py-2.5 text-[14px] text-slate-900 outline-none placeholder:text-slate-400 focus:border-slate-900';
const labelClassName = 'mb-2 block text-[12px] font-medium text-slate-600';
const actionButtonClassName =
'inline-flex items-center justify-center rounded-[8px] border px-4 py-2 text-sm font-medium';
const initialFormValues = {
firstName: '',
lastName: '',
phoneNumber: '',
email: '',
avatar: [],
password: '',
};
function getAvatarUrl(avatar: any) {
if (!Array.isArray(avatar) || !avatar.length) {
return '';
}
return avatar[0]?.publicUrl || '';
}
function getProfileInitials(currentUser: any, formValues: typeof initialFormValues) {
const firstName = formValues.firstName || currentUser?.firstName || '';
const lastName = formValues.lastName || currentUser?.lastName || '';
const initials = `${firstName.slice(0, 1)}${lastName.slice(0, 1)}`.trim();
if (initials) {
return initials.toUpperCase();
}
if (currentUser?.email) {
return currentUser.email.slice(0, 1).toUpperCase();
}
return 'U';
}
const ProfilePage = () => { const ProfilePage = () => {
const { currentUser } = useAppSelector( const { currentUser } = useAppSelector((state) => state.auth);
(state) => state.auth, const dispatch = useAppDispatch();
); const [initialValues, setInitialValues] = useState(initialFormValues);
const router = useRouter();
const dispatch = useAppDispatch();
const notify = (type, msg) => toast(msg, { type });
const initVals = {
firstName: '',
lastName: '',
phoneNumber: '',
email: '',
avatar: [],
password: ''
};
const [initialValues, setInitialValues] = useState(initVals);
useEffect(() => { useEffect(() => {
if (currentUser?.id && typeof currentUser === 'object') { if (!currentUser?.id || typeof currentUser !== 'object') {
const newInitialVal = { ...initVals }; return;
}
Object.keys(initVals).forEach( setInitialValues({
(el) => (newInitialVal[el] = currentUser[el]), firstName: currentUser.firstName || '',
); lastName: currentUser.lastName || '',
phoneNumber: currentUser.phoneNumber || '',
email: currentUser.email || '',
avatar: currentUser.avatar || [],
password: '',
});
}, [currentUser]);
setInitialValues(newInitialVal); const handleSubmit = async (data: typeof initialFormValues) => {
} await dispatch(update({ id: currentUser.id, data }));
}, [currentUser]); await dispatch(findMe());
toast('Profile updated.', { type: 'success', position: 'bottom-center' });
};
const handleSubmit = async (data) => { return (
await dispatch(update({ id: currentUser.id, data })); <>
await dispatch(findMe()); <Head>
await router.push('/settings'); <title>{getPageTitle('Profile')}</title>
notify('success', 'Profile was updated!'); </Head>
}; <SectionMain>
<div className="mx-auto flex w-full max-w-5xl flex-col gap-5">
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<Link
className="inline-flex items-center gap-2 text-[12px] font-medium text-slate-500"
href="/settings"
>
<BaseIcon path={mdiArrowLeft} size={14} />
Back to settings
</Link>
<p className="mt-4 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
Profile
</p>
<h1 className="mt-3 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900">
Manage your account details.
</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-500">
Keep your name, avatar, contact information, and password current without leaving the workspace.
</p>
</div>
return ( <Formik
<> enableReinitialize
<Head> initialValues={initialValues}
<title>{getPageTitle('Profile')}</title> onSubmit={(values) => handleSubmit(values)}
</Head> >
<SectionMain> {({ isSubmitting, resetForm, setFieldValue, values }) => {
<SectionTitleLineWithButton const previewUrl = getAvatarUrl(values.avatar);
icon={mdiChartTimelineVariant} const initials = getProfileInitials(currentUser, values);
title='Profile'
main return (
> <Form>
{''} <div className="grid gap-5 xl:grid-cols-[280px_minmax(0,1fr)]">
</SectionTitleLineWithButton> <div className="space-y-5">
<CardBox> <div className="rounded-[12px] border border-slate-200 bg-white px-5 py-5">
{currentUser?.avatar[0]?.publicUrl && <div className={'grid grid-cols-6 gap-4 mb-4'}> <p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
<div className="col-span-1 w-80 h-80 overflow-hidden border-2 rounded-full inline-flex items-center justify-center mb-8"> Account
<img className="w-80 h-80 max-w-full max-h-full object-cover object-center" src={`${currentUser?.avatar[0]?.publicUrl}`} alt="Avatar" /> </p>
<div className="mt-4 flex flex-col items-start gap-4">
<div className="flex h-24 w-24 items-center justify-center overflow-hidden rounded-[12px] border border-slate-200 bg-slate-100 text-[28px] font-semibold text-slate-600">
{previewUrl ? (
<img
alt="Profile avatar"
className="h-full w-full object-cover"
src={previewUrl}
/>
) : (
initials
)}
</div>
<div>
<p className="text-[15px] font-medium text-slate-900">
{values.firstName || values.lastName
? `${values.firstName} ${values.lastName}`.trim()
: 'Workspace user'}
</p>
<p className="mt-1 text-sm text-slate-500">
{values.email || currentUser?.email || 'No email'}
</p>
</div>
</div> </div>
</div>} <div className="mt-5">
<Formik <span className={labelClassName}>Avatar</span>
enableReinitialize <div className="flex flex-wrap items-center gap-2">
initialValues={initialValues} <Field
onSubmit={(values) => handleSubmit(values)} color="info"
> component={FormImagePicker}
<Form> icon={mdiUpload}
<FormField> id="avatar"
<Field label="Upload image"
label='Avatar' name="avatar"
color='info' path="users/avatar"
icon={mdiUpload} schema={{
path={'users/avatar'} size: undefined,
name='avatar' formats: undefined,
id='avatar' }}
schema={{ />
size: undefined, {previewUrl && (
formats: undefined, <button
}} className={`${actionButtonClassName} border-slate-200 bg-white text-slate-600`}
component={FormImagePicker} onClick={() => {
></Field> setFieldValue('avatar', []);
</FormField> }}
<FormField label='First Name'> type="button"
<Field name='firstName' placeholder='First Name' /> >
</FormField> Remove avatar
</button>
)}
</div>
</div>
</div>
<FormField label='Last Name'> <div className="rounded-[12px] border border-slate-200 bg-white px-5 py-5">
<Field name='lastName' placeholder='Last Name' /> <div className="flex items-start gap-3">
</FormField> <div className="mt-0.5 inline-flex h-9 w-9 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
<BaseIcon path={mdiLockOutline} size={18} />
</div>
<div>
<p className="text-[15px] font-medium text-slate-900">Password</p>
<p className="mt-1 text-sm leading-6 text-slate-500">
Leave the password field blank if you do not want to change it.
</p>
</div>
</div>
</div>
</div>
<FormField label='Phone Number'> <div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<Field name='phoneNumber' placeholder='Phone Number' /> <div className="mb-5 flex items-start gap-3">
</FormField> <div className="mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
<BaseIcon path={mdiAccountCircleOutline} size={20} />
</div>
<div>
<p className="text-[15px] font-medium text-slate-900">Personal details</p>
<p className="mt-1 text-sm leading-6 text-slate-500">
Update the information teammates will see around the workspace.
</p>
</div>
</div>
<FormField label='E-Mail'> <div className="grid gap-4 md:grid-cols-2">
<Field name='email' placeholder='E-Mail' disabled /> <div>
</FormField> <label className={labelClassName} htmlFor="firstName">
First name
</label>
<Field className={inputClassName} id="firstName" name="firstName" placeholder="Alex" />
</div>
<div>
<label className={labelClassName} htmlFor="lastName">
Last name
</label>
<Field className={inputClassName} id="lastName" name="lastName" placeholder="Blari" />
</div>
<div>
<label className={labelClassName} htmlFor="phoneNumber">
Phone number
</label>
<div className="relative">
<BaseIcon
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
path={mdiPhoneOutline}
size={16}
/>
<Field
className={`${inputClassName} pl-10`}
id="phoneNumber"
name="phoneNumber"
placeholder="+1 (555) 000-0000"
/>
</div>
</div>
<div>
<label className={labelClassName} htmlFor="email">
Email
</label>
<Field
className={`${inputClassName} cursor-not-allowed bg-slate-50 text-slate-500`}
disabled
id="email"
name="email"
placeholder="name@company.com"
/>
</div>
</div>
<FormField <div className="mt-6 rounded-[12px] border border-slate-200 bg-slate-50 px-4 py-4">
label="Password" <div className="mb-3 flex items-start gap-3">
> <div className="mt-0.5 inline-flex h-9 w-9 items-center justify-center rounded-[10px] border border-slate-200 bg-white text-slate-600">
<Field <BaseIcon path={mdiCogOutline} size={18} />
name="password" </div>
placeholder="password" <div>
/> <p className="text-[15px] font-medium text-slate-900">Security</p>
</FormField> <p className="mt-1 text-sm leading-6 text-slate-500">
Set a new password only when you need to rotate credentials.
</p>
</div>
</div>
<div>
<label className={labelClassName} htmlFor="password">
New password
</label>
<Field
className={inputClassName}
id="password"
name="password"
placeholder="Leave blank to keep the current password"
type="password"
/>
</div>
</div>
<BaseDivider /> <div className="mt-6 flex flex-wrap items-center gap-3 border-t border-slate-200 pt-5">
<button
<BaseButtons> className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white disabled:cursor-not-allowed disabled:opacity-60`}
<BaseButton type='submit' color='info' label='Submit' /> disabled={isSubmitting}
<BaseButton type='reset' color='info' outline label='Reset' /> type="submit"
<BaseButton >
type='reset' {isSubmitting ? 'Saving...' : 'Save changes'}
color='danger' </button>
outline <button
label='Cancel' className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
onClick={() => router.push('/settings')} onClick={() => resetForm()}
/> type="reset"
</BaseButtons> >
</Form> Reset
</Formik> </button>
</CardBox> <Link
</SectionMain> className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
</> href="/settings"
); >
Cancel
</Link>
</div>
</div>
</div>
</Form>
);
}}
</Formik>
</div>
</SectionMain>
</>
);
}; };
ProfilePage.getLayout = function getLayout(page: ReactElement) { ProfilePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>; return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
}; };
export default ProfilePage; export default ProfilePage;

View File

@ -1,87 +1,61 @@
import { mdiChartTimelineVariant } from '@mdi/js' import Head from 'next/head';
import Head from 'next/head' import Link from 'next/link';
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash';
import React, { ReactElement, useState } from 'react' import React, { ReactElement, useState } from 'react';
import CardBox from '../../components/CardBox' import axios from 'axios';
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import TableRoles from '../../components/Roles/TableRoles'
import BaseButton from '../../components/BaseButton'
import axios from "axios";
import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker";
import {setRefetch, uploadCsv} from '../../stores/roles/rolesSlice';
import CardBox from '../../components/CardBox';
import TableRoles from '../../components/Roles/TableRoles';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import { hasPermission } from '../../helpers/userPermissions';
import LayoutAuthenticated from '../../layouts/Authenticated';
import { useAppSelector } from '../../stores/hooks';
import {hasPermission} from "../../helpers/userPermissions"; const primaryActionClassName =
'inline-flex items-center justify-center rounded-[8px] bg-slate-900 px-4 py-2 text-sm font-medium text-white';
const secondaryActionClassName =
'inline-flex items-center justify-center rounded-[8px] border border-slate-200 bg-white px-3.5 py-2 text-sm font-medium text-slate-700';
const RolesTablesPage = () => { const RolesTablesPage = () => {
const [filterItems, setFilterItems] = useState([]); const [filterItems, setFilterItems] = useState([]);
const [csvFile, setCsvFile] = useState<File | null>(null);
const [isModalActive, setIsModalActive] = useState(false);
const [showTableView, setShowTableView] = useState(false);
const { currentUser } = useAppSelector((state) => state.auth); const { currentUser } = useAppSelector((state) => state.auth);
const dispatch = useAppDispatch(); const [filters] = useState([
{ label: 'Name', title: 'name' },
{ label: 'Permissions', title: 'permissions' },
const [filters] = useState([{label: 'Name', title: 'name'},
{label: 'Permissions', title: 'permissions'},
]); ]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ROLES');
const addFilter = () => { const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ROLES');
const newItem = {
id: uniqueId(), const addFilter = () => {
fields: { const newItem = {
filterValue: '', id: uniqueId(),
filterValueFrom: '', fields: {
filterValueTo: '', filterValue: '',
selectedField: '', filterValueFrom: '',
}, filterValueTo: '',
}; selectedField: filters[0].title,
newItem.fields.selectedField = filters[0].title; },
setFilterItems([...filterItems, newItem]);
}; };
const getRolesCSV = async () => { setFilterItems([...filterItems, newItem]);
const response = await axios({url: '/roles?filetype=csv', method: 'GET',responseType: 'blob'}); };
const type = response.headers['content-type']
const blob = new Blob([response.data], { type: type })
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = 'rolesCSV.csv'
link.click()
};
const onModalConfirm = async () => { const getRolesCSV = async () => {
if (!csvFile) return; const response = await axios({
await dispatch(uploadCsv(csvFile)); url: '/roles?filetype=csv',
dispatch(setRefetch(true)); method: 'GET',
setCsvFile(null); responseType: 'blob',
setIsModalActive(false); });
}; const type = response.headers['content-type'];
const blob = new Blob([response.data], { type });
const onModalCancel = () => { const link = document.createElement('a');
setCsvFile(null); link.href = window.URL.createObjectURL(blob);
setIsModalActive(false); link.download = 'rolesCSV.csv';
}; link.click();
};
return ( return (
<> <>
@ -89,74 +63,58 @@ const RolesTablesPage = () => {
<title>{getPageTitle('Roles')}</title> <title>{getPageTitle('Roles')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Roles" main> <div className="mb-5 rounded-[10px] border border-slate-200 bg-white px-5 py-5">
{''} <div className="flex flex-wrap items-start justify-between gap-4">
</SectionTitleLineWithButton> <div className="min-w-0">
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'> <p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
Access control
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/roles/roles-new'} color='info' label='New Item'/>} </p>
<h1 className="mt-2 text-[1.65rem] font-semibold tracking-[-0.04em] text-slate-900">
<BaseButton Roles
className={'mr-3'} </h1>
color='info' <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
label='Filter' Group permissions into a few clear access levels and keep role names readable.
onClick={addFilter} </p>
/> </div>
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getRolesCSV} />
{hasCreatePermission && ( {hasCreatePermission && (
<BaseButton <Link className={primaryActionClassName} href="/roles/roles-new">
color='info' New role
label='Upload CSV' </Link>
onClick={() => setIsModalActive(true)}
/>
)} )}
</div>
<div className='md:inline-flex items-center ms-auto'> </div>
<div id='delete-rows-button'></div>
</div> <div className="mb-4 flex flex-wrap items-center gap-2 rounded-[10px] border border-slate-200 bg-slate-50 px-3.5 py-3">
<button className={secondaryActionClassName} onClick={addFilter} type="button">
</CardBox> Add filter
</button>
<CardBox className="mb-6" hasTable> <button
className={secondaryActionClassName}
onClick={() => void getRolesCSV()}
type="button"
>
Download CSV
</button>
<div className="ml-auto flex items-center">
<div id="delete-rows-button" />
</div>
</div>
<CardBox className="mb-6 rounded-[10px] border border-slate-200 bg-white shadow-none" hasTable>
<TableRoles <TableRoles
filterItems={filterItems} filterItems={filterItems}
setFilterItems={setFilterItems}
filters={filters} filters={filters}
setFilterItems={setFilterItems}
showGrid={false} showGrid={false}
/>
</CardBox>
</SectionMain>
<CardBoxModal
title='Upload CSV'
buttonColor='info'
buttonLabel={'Confirm'}
// buttonLabel={false ? 'Deleting...' : 'Confirm'}
isActive={isModalActive}
onConfirm={onModalConfirm}
onCancel={onModalCancel}
>
<DragDropFilePicker
file={csvFile}
setFile={setCsvFile}
formats={'.csv'}
/> />
</CardBoxModal> </CardBox>
</SectionMain>
</> </>
) );
} };
RolesTablesPage.getLayout = function getLayout(page: ReactElement) { RolesTablesPage.getLayout = function getLayout(page: ReactElement) {
return ( return <LayoutAuthenticated permission="READ_ROLES">{page}</LayoutAuthenticated>;
<LayoutAuthenticated };
permission={'READ_ROLES'}
>
{page}
</LayoutAuthenticated>
)
}
export default RolesTablesPage export default RolesTablesPage;

View File

@ -1,178 +1,116 @@
import { mdiChartTimelineVariant } from '@mdi/js' import Head from 'next/head';
import Head from 'next/head'
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash';
import React, { ReactElement, useState } from 'react' import React, { ReactElement, useState } from 'react';
import CardBox from '../../components/CardBox' import axios from 'axios';
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import TableUsage_events from '../../components/Usage_events/TableUsage_events'
import BaseButton from '../../components/BaseButton'
import axios from "axios";
import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker";
import {setRefetch, uploadCsv} from '../../stores/usage_events/usage_eventsSlice';
import {hasPermission} from "../../helpers/userPermissions";
import CardBox from '../../components/CardBox';
import TableUsage_events from '../../components/Usage_events/TableUsage_events';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import LayoutAuthenticated from '../../layouts/Authenticated';
const secondaryActionClassName =
'inline-flex items-center justify-center rounded-[8px] border border-slate-200 bg-white px-3.5 py-2 text-sm font-medium text-slate-700';
const Usage_eventsTablesPage = () => { const Usage_eventsTablesPage = () => {
const [filterItems, setFilterItems] = useState([]); const [filterItems, setFilterItems] = useState([]);
const [csvFile, setCsvFile] = useState<File | null>(null);
const [isModalActive, setIsModalActive] = useState(false);
const [showTableView, setShowTableView] = useState(false);
const [filters] = useState([
const { currentUser } = useAppSelector((state) => state.auth); { label: 'Provider', title: 'provider' },
{ label: 'Model', title: 'model' },
{ label: 'MetadataJSON', title: 'metadata_json' },
const dispatch = useAppDispatch(); { label: 'Inputtokens', title: 'input_tokens', number: 'true' },
{ label: 'Outputtokens', title: 'output_tokens', number: 'true' },
{ label: 'Totaltokens', title: 'total_tokens', number: 'true' },
const [filters] = useState([{label: 'Provider', title: 'provider'},{label: 'Model', title: 'model'},{label: 'MetadataJSON', title: 'metadata_json'}, { label: 'CostUSD', title: 'cost_usd', number: 'true' },
{label: 'Inputtokens', title: 'input_tokens', number: 'true'},{label: 'Outputtokens', title: 'output_tokens', number: 'true'},{label: 'Totaltokens', title: 'total_tokens', number: 'true'}, { label: 'Occurredat', title: 'occurred_at', date: 'true' },
{label: 'CostUSD', title: 'cost_usd', number: 'true'}, { label: 'User', title: 'user' },
{label: 'Occurredat', title: 'occurred_at', date: 'true'}, { label: 'Conversation', title: 'conversation' },
{ label: 'Message', title: 'message' },
{ label: 'Agent', title: 'agent' },
{label: 'User', title: 'user'}, {
label: 'Eventtype',
title: 'event_type',
type: 'enum',
{label: 'Conversation', title: 'conversation'}, options: ['message_sent', 'message_generated', 'tokens_counted', 'cost_incurred', 'attachment_uploaded'],
},
{label: 'Message', title: 'message'},
{label: 'Agent', title: 'agent'},
{label: 'Eventtype', title: 'event_type', type: 'enum', options: ['message_sent','message_generated','tokens_counted','cost_incurred','attachment_uploaded']},
]); ]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_USAGE_EVENTS');
const addFilter = () => { const addFilter = () => {
const newItem = { const newItem = {
id: uniqueId(), id: uniqueId(),
fields: { fields: {
filterValue: '', filterValue: '',
filterValueFrom: '', filterValueFrom: '',
filterValueTo: '', filterValueTo: '',
selectedField: '', selectedField: filters[0].title,
}, },
};
newItem.fields.selectedField = filters[0].title;
setFilterItems([...filterItems, newItem]);
}; };
const getUsage_eventsCSV = async () => { setFilterItems([...filterItems, newItem]);
const response = await axios({url: '/usage_events?filetype=csv', method: 'GET',responseType: 'blob'}); };
const type = response.headers['content-type']
const blob = new Blob([response.data], { type: type })
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = 'usage_eventsCSV.csv'
link.click()
};
const onModalConfirm = async () => { const getUsageEventsCSV = async () => {
if (!csvFile) return; const response = await axios({
await dispatch(uploadCsv(csvFile)); url: '/usage_events?filetype=csv',
dispatch(setRefetch(true)); method: 'GET',
setCsvFile(null); responseType: 'blob',
setIsModalActive(false); });
}; const type = response.headers['content-type'];
const blob = new Blob([response.data], { type });
const onModalCancel = () => { const link = document.createElement('a');
setCsvFile(null); link.href = window.URL.createObjectURL(blob);
setIsModalActive(false); link.download = 'usage_eventsCSV.csv';
}; link.click();
};
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Usage_events')}</title> <title>{getPageTitle('Usage events')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Usage_events" main> <div className="mb-5 rounded-[10px] border border-slate-200 bg-white px-5 py-5">
{''} <p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">Admin</p>
</SectionTitleLineWithButton> <h1 className="mt-2 text-[1.65rem] font-semibold tracking-[-0.04em] text-slate-900">
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'> Usage events
</h1>
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/usage_events/usage_events-new'} color='info' label='New Item'/>} <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
Track model activity, token usage, and cost signals without leaving the backoffice.
<BaseButton </p>
className={'mr-3'} </div>
color='info'
label='Filter' <div className="mb-4 flex flex-wrap items-center gap-2 rounded-[10px] border border-slate-200 bg-slate-50 px-3.5 py-3">
onClick={addFilter} <button className={secondaryActionClassName} onClick={addFilter} type="button">
/> Add filter
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getUsage_eventsCSV} /> </button>
<button
{hasCreatePermission && ( className={secondaryActionClassName}
<BaseButton onClick={() => void getUsageEventsCSV()}
color='info' type="button"
label='Upload CSV' >
onClick={() => setIsModalActive(true)} Download CSV
/> </button>
)} <div className="ml-auto flex items-center">
<div id="delete-rows-button" />
<div className='md:inline-flex items-center ms-auto'> </div>
<div id='delete-rows-button'></div> </div>
</div>
<CardBox className="mb-6 rounded-[10px] border border-slate-200 bg-white shadow-none" hasTable>
</CardBox>
<CardBox className="mb-6" hasTable>
<TableUsage_events <TableUsage_events
filterItems={filterItems} filterItems={filterItems}
setFilterItems={setFilterItems}
filters={filters} filters={filters}
setFilterItems={setFilterItems}
showGrid={false} showGrid={false}
/>
</CardBox>
</SectionMain>
<CardBoxModal
title='Upload CSV'
buttonColor='info'
buttonLabel={'Confirm'}
// buttonLabel={false ? 'Deleting...' : 'Confirm'}
isActive={isModalActive}
onConfirm={onModalConfirm}
onCancel={onModalCancel}
>
<DragDropFilePicker
file={csvFile}
setFile={setCsvFile}
formats={'.csv'}
/> />
</CardBoxModal> </CardBox>
</SectionMain>
</> </>
) );
} };
Usage_eventsTablesPage.getLayout = function getLayout(page: ReactElement) { Usage_eventsTablesPage.getLayout = function getLayout(page: ReactElement) {
return ( return <LayoutAuthenticated permission="READ_USAGE_EVENTS">{page}</LayoutAuthenticated>;
<LayoutAuthenticated };
permission={'READ_USAGE_EVENTS'}
>
{page}
</LayoutAuthenticated>
)
}
export default Usage_eventsTablesPage export default Usage_eventsTablesPage;

View File

@ -1,166 +1,165 @@
import { mdiChartTimelineVariant } from '@mdi/js' import Head from 'next/head';
import Head from 'next/head' import Link from 'next/link';
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash';
import React, { ReactElement, useState } from 'react' import React, { ReactElement, useState } from 'react';
import CardBox from '../../components/CardBox' import axios from 'axios';
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import TableUsers from '../../components/Users/TableUsers'
import BaseButton from '../../components/BaseButton'
import axios from "axios";
import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker";
import {setRefetch, uploadCsv} from '../../stores/users/usersSlice';
import CardBox from '../../components/CardBox';
import CardBoxModal from '../../components/CardBoxModal';
import DragDropFilePicker from '../../components/DragDropFilePicker';
import TableUsers from '../../components/Users/TableUsers';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import { hasPermission } from '../../helpers/userPermissions';
import LayoutAuthenticated from '../../layouts/Authenticated';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { setRefetch, uploadCsv } from '../../stores/users/usersSlice';
import {hasPermission} from "../../helpers/userPermissions"; const primaryActionClassName =
'inline-flex items-center justify-center rounded-[8px] bg-slate-900 px-4 py-2 text-sm font-medium text-white';
const secondaryActionClassName =
'inline-flex items-center justify-center rounded-[8px] border border-slate-200 bg-white px-3.5 py-2 text-sm font-medium text-slate-700';
const UsersTablesPage = () => { const UsersTablesPage = () => {
const [filterItems, setFilterItems] = useState([]); const [filterItems, setFilterItems] = useState([]);
const [csvFile, setCsvFile] = useState<File | null>(null); const [csvFile, setCsvFile] = useState<File | null>(null);
const [isModalActive, setIsModalActive] = useState(false); const [isModalActive, setIsModalActive] = useState(false);
const [showTableView, setShowTableView] = useState(false);
const { currentUser } = useAppSelector((state) => state.auth); const { currentUser } = useAppSelector((state) => state.auth);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [filters] = useState([
const [filters] = useState([{label: 'First Name', title: 'firstName'},{label: 'Last Name', title: 'lastName'},{label: 'Phone Number', title: 'phoneNumber'},{label: 'E-Mail', title: 'email'}, { label: 'First Name', title: 'firstName' },
{ label: 'Last Name', title: 'lastName' },
{ label: 'Phone Number', title: 'phoneNumber' },
{ label: 'E-Mail', title: 'email' },
{ label: 'App Role', title: 'app_role' },
{ label: 'Custom Permissions', title: 'custom_permissions' },
{label: 'App Role', title: 'app_role'},
{label: 'Custom Permissions', title: 'custom_permissions'},
]); ]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_USERS');
const addFilter = () => { const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_USERS');
const newItem = {
id: uniqueId(), const addFilter = () => {
fields: { const newItem = {
filterValue: '', id: uniqueId(),
filterValueFrom: '', fields: {
filterValueTo: '', filterValue: '',
selectedField: '', filterValueFrom: '',
}, filterValueTo: '',
}; selectedField: filters[0].title,
newItem.fields.selectedField = filters[0].title; },
setFilterItems([...filterItems, newItem]);
}; };
const getUsersCSV = async () => { setFilterItems([...filterItems, newItem]);
const response = await axios({url: '/users?filetype=csv', method: 'GET',responseType: 'blob'}); };
const type = response.headers['content-type']
const blob = new Blob([response.data], { type: type })
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = 'usersCSV.csv'
link.click()
};
const onModalConfirm = async () => { const getUsersCSV = async () => {
if (!csvFile) return; const response = await axios({
await dispatch(uploadCsv(csvFile)); url: '/users?filetype=csv',
dispatch(setRefetch(true)); method: 'GET',
setCsvFile(null); responseType: 'blob',
setIsModalActive(false); });
}; const type = response.headers['content-type'];
const blob = new Blob([response.data], { type });
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = 'usersCSV.csv';
link.click();
};
const onModalCancel = () => { const onModalConfirm = async () => {
setCsvFile(null); if (!csvFile) {
setIsModalActive(false); return;
}; }
await dispatch(uploadCsv(csvFile));
dispatch(setRefetch(true));
setCsvFile(null);
setIsModalActive(false);
};
const onModalCancel = () => {
setCsvFile(null);
setIsModalActive(false);
};
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Users')}</title> <title>{getPageTitle('People')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Users" main> <div className="mb-5 rounded-[10px] border border-slate-200 bg-white px-5 py-5">
{''} <div className="flex flex-wrap items-start justify-between gap-4">
</SectionTitleLineWithButton> <div className="min-w-0">
<CardBox id="usersList" className='mb-6' cardBoxClassName='flex flex-wrap'> <p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
Admin
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/users/users-new'} color='info' label='Add/Invite User'/>} </p>
<h1 className="mt-2 text-[1.65rem] font-semibold tracking-[-0.04em] text-slate-900">
<BaseButton People
className={'mr-3'} </h1>
color='info' <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
label='Filter' Invite teammates, review workspace access, and keep account ownership visible.
onClick={addFilter} </p>
/> </div>
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getUsersCSV} />
{hasCreatePermission && ( {hasCreatePermission && (
<BaseButton <Link className={primaryActionClassName} href="/users/users-new">
color='info' Invite user
label='Upload CSV' </Link>
onClick={() => setIsModalActive(true)}
/>
)} )}
</div>
<div className='md:inline-flex items-center ms-auto'> </div>
<div id='delete-rows-button'></div>
</div> <div className="mb-4 flex flex-wrap items-center gap-2 rounded-[10px] border border-slate-200 bg-slate-50 px-3.5 py-3">
<button className={secondaryActionClassName} onClick={addFilter} type="button">
</CardBox> Add filter
</button>
<CardBox className="mb-6" hasTable> <button
className={secondaryActionClassName}
onClick={() => void getUsersCSV()}
type="button"
>
Download CSV
</button>
{hasCreatePermission && (
<button
className={secondaryActionClassName}
onClick={() => setIsModalActive(true)}
type="button"
>
Upload CSV
</button>
)}
<div className="ml-auto flex items-center">
<div id="delete-rows-button" />
</div>
</div>
<CardBox className="mb-6 rounded-[10px] border border-slate-200 bg-white shadow-none" hasTable>
<TableUsers <TableUsers
filterItems={filterItems} filterItems={filterItems}
setFilterItems={setFilterItems}
filters={filters} filters={filters}
setFilterItems={setFilterItems}
showGrid={false} showGrid={false}
/> />
</CardBox> </CardBox>
</SectionMain> </SectionMain>
<CardBoxModal <CardBoxModal
title='Upload CSV' buttonColor="info"
buttonColor='info' buttonLabel="Confirm"
buttonLabel={'Confirm'} isActive={isModalActive}
// buttonLabel={false ? 'Deleting...' : 'Confirm'} onCancel={onModalCancel}
isActive={isModalActive} onConfirm={onModalConfirm}
onConfirm={onModalConfirm} title="Upload CSV"
onCancel={onModalCancel}
> >
<DragDropFilePicker <DragDropFilePicker file={csvFile} formats=".csv" setFile={setCsvFile} />
file={csvFile}
setFile={setCsvFile}
formats={'.csv'}
/>
</CardBoxModal> </CardBoxModal>
</> </>
) );
} };
UsersTablesPage.getLayout = function getLayout(page: ReactElement) { UsersTablesPage.getLayout = function getLayout(page: ReactElement) {
return ( return <LayoutAuthenticated permission="READ_USERS">{page}</LayoutAuthenticated>;
<LayoutAuthenticated };
permission={'READ_USERS'}
>
{page}
</LayoutAuthenticated>
)
}
export default UsersTablesPage export default UsersTablesPage;