708 lines
22 KiB
TypeScript
708 lines
22 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Paper,
|
|
Avatar,
|
|
IconButton,
|
|
Chip,
|
|
useMediaQuery,
|
|
Tooltip,
|
|
Fab,
|
|
Zoom,
|
|
} from '@mui/material';
|
|
import { useTheme } from '@mui/material/styles';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import {
|
|
User,
|
|
Bot,
|
|
Sparkles,
|
|
ChevronDown,
|
|
RotateCcw,
|
|
Copy,
|
|
Check,
|
|
AlertCircle,
|
|
} from 'lucide-react';
|
|
import Logo from './Logo';
|
|
import { useChatStore, type MessageAttachment } from '../services/chat-store';
|
|
import { useAuthStore } from '../services/auth-store';
|
|
import { useToolsStore } from '../services/tools-store';
|
|
import { useReservationStore } from '../services/reservation-store';
|
|
import { MOCK_USERS } from '../services/mock-data';
|
|
import { fileToDataUrl } from '../services/file-utils';
|
|
import AIInput from './AIInput';
|
|
import MarkdownMessage from './MarkdownMessage';
|
|
|
|
interface ChatApiResponse {
|
|
content: string;
|
|
mode?: 'live' | 'mock' | 'mock-fallback';
|
|
matchedTools?: unknown[];
|
|
sources?: string[];
|
|
error?: string;
|
|
}
|
|
|
|
const SUGGESTED_PROMPTS = [
|
|
{ icon: '🔧', text: 'Wie repariere ich ein tropfendes Fahrrad-Ventil?' },
|
|
{ icon: '🪛', text: 'Welches Werkzeug brauche ich zum Bohren in Beton?' },
|
|
{ icon: '🖨️', text: 'Tipps für meinen ersten 3D-Druck mit PLA' },
|
|
{ icon: '📅', text: 'Wann ist das Repair Café geöffnet?' },
|
|
];
|
|
|
|
function formatTime(ts: number) {
|
|
return new Date(ts).toLocaleTimeString('de-DE', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
export default function ChatWindow() {
|
|
const theme = useTheme();
|
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
|
const { sessions, activeSessionId, addMessage, createSession } = useChatStore();
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [lastMode, setLastMode] = useState<ChatApiResponse['mode'] | null>(null);
|
|
const [showScrollDown, setShowScrollDown] = useState(false);
|
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
const activeSession = useMemo(
|
|
() => sessions.find((s) => s.id === activeSessionId) ?? null,
|
|
[sessions, activeSessionId]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!activeSession || activeSession.messages.length === 0) return;
|
|
const last = activeSession.messages[activeSession.messages.length - 1];
|
|
if (last.role === 'user' && !isLoading) {
|
|
void sendToApi(activeSession.id);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [activeSession?.messages.length]);
|
|
|
|
useEffect(() => {
|
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
}, [activeSession?.messages.length, isLoading]);
|
|
|
|
useEffect(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
const onScroll = () => {
|
|
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
setShowScrollDown(distance > 200);
|
|
};
|
|
el.addEventListener('scroll', onScroll);
|
|
return () => el.removeEventListener('scroll', onScroll);
|
|
}, [activeSessionId]);
|
|
|
|
const sendToApi = async (sessionId: string) => {
|
|
const current = useChatStore.getState().sessions.find((s) => s.id === sessionId);
|
|
if (!current) return;
|
|
setIsLoading(true);
|
|
try {
|
|
const history = current.messages.map((m) => ({
|
|
role: m.role,
|
|
content: m.content,
|
|
attachments: m.attachments,
|
|
}));
|
|
const user = useAuthStore.getState().user;
|
|
const tools = useToolsStore.getState().tools;
|
|
const reservations = useReservationStore.getState().reservations;
|
|
const context = {
|
|
userRole: user?.role,
|
|
username: user?.username,
|
|
tools,
|
|
reservations,
|
|
users: MOCK_USERS.map((u) => ({ id: u.id, username: u.username, role: u.role })),
|
|
};
|
|
const response = await fetch('/api/chat', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ messages: history, context }),
|
|
});
|
|
const data = (await response.json()) as ChatApiResponse;
|
|
if (data.error || !data.content) throw new Error(data.error || 'Empty response');
|
|
setLastMode(data.mode ?? null);
|
|
addMessage(sessionId, { role: 'assistant', content: data.content });
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
|
addMessage(sessionId, {
|
|
role: 'assistant',
|
|
content: `⚠️ Es gab ein Problem bei der Verbindung: ${msg}`,
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSend = async (content: string, files?: File[]) => {
|
|
const hasFiles = Boolean(files && files.length > 0);
|
|
if (!content.trim() && !hasFiles) return;
|
|
let targetId = activeSessionId;
|
|
if (!targetId) targetId = createSession(content.slice(0, 30) || 'Bild-Analyse');
|
|
|
|
let attachments: MessageAttachment[] | undefined;
|
|
if (hasFiles) {
|
|
const processed = await Promise.all(
|
|
files!.map(async (file) => {
|
|
const { dataUrl, type, size } = await fileToDataUrl(file);
|
|
return { name: file.name, type, dataUrl, size };
|
|
})
|
|
);
|
|
attachments = processed;
|
|
}
|
|
|
|
addMessage(targetId, {
|
|
role: 'user',
|
|
content: content.trim(),
|
|
attachments,
|
|
});
|
|
};
|
|
|
|
const handleSuggested = (text: string) => handleSend(text);
|
|
|
|
const handleCopy = async (id: string, text: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
setCopiedId(id);
|
|
setTimeout(() => setCopiedId((cur) => (cur === id ? null : cur)), 1500);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
};
|
|
|
|
const handleRetry = () => {
|
|
if (!activeSession || activeSession.messages.length === 0 || !activeSessionId) return;
|
|
const lastUser = [...activeSession.messages].reverse().find((m) => m.role === 'user');
|
|
if (lastUser) void sendToApi(activeSessionId);
|
|
};
|
|
|
|
const scrollToBottom = () =>
|
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
|
|
if (!activeSession) return <EmptyState onPick={handleSuggested} />;
|
|
|
|
const isEmpty = activeSession.messages.length === 0;
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
flexGrow: 1,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
height: '100%',
|
|
bgcolor: 'background.default',
|
|
position: 'relative',
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<Box
|
|
sx={{
|
|
px: { xs: 2, md: 3 },
|
|
py: 1.5,
|
|
borderBottom: '1px solid',
|
|
borderColor: 'divider',
|
|
bgcolor: 'background.paper',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 1.5,
|
|
minHeight: 56,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
width: 34,
|
|
height: 34,
|
|
borderRadius: 2.5,
|
|
background: 'linear-gradient(135deg, #FF6B00, #E55F00)',
|
|
color: 'white',
|
|
display: 'grid',
|
|
placeItems: 'center',
|
|
flexShrink: 0,
|
|
boxShadow: 'var(--pp-shadow-brand)',
|
|
}}
|
|
>
|
|
<Bot size={16} />
|
|
</Box>
|
|
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
|
<Typography
|
|
variant="subtitle1"
|
|
sx={{
|
|
fontWeight: 700,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
fontSize: '0.95rem',
|
|
lineHeight: 1.2,
|
|
}}
|
|
>
|
|
{activeSession.title}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.72rem' }}>
|
|
{activeSession.messages.length} Nachrichten · PatchPilot-KI
|
|
</Typography>
|
|
</Box>
|
|
<ModeBadge mode={lastMode} />
|
|
</Box>
|
|
|
|
{/* Messages */}
|
|
<Box
|
|
ref={scrollRef}
|
|
sx={{
|
|
flexGrow: 1,
|
|
overflowY: 'auto',
|
|
px: { xs: 2, md: 4 },
|
|
py: { xs: 2.5, md: 3.5 },
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 2,
|
|
}}
|
|
>
|
|
{isEmpty && (
|
|
<Box sx={{ m: 'auto', textAlign: 'center', maxWidth: 480, py: 4 }}>
|
|
<Box
|
|
sx={{
|
|
width: 52,
|
|
height: 52,
|
|
mx: 'auto',
|
|
borderRadius: 3,
|
|
display: 'grid',
|
|
placeItems: 'center',
|
|
background: 'linear-gradient(135deg, #FF6B00, #E55F00)',
|
|
color: 'white',
|
|
mb: 2,
|
|
boxShadow: 'var(--pp-shadow-brand)',
|
|
}}
|
|
>
|
|
<Sparkles size={24} />
|
|
</Box>
|
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }}>
|
|
Worüber sollen wir reden?
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Wähle einen Vorschlag unten oder schreibe deine Frage.
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
|
|
<AnimatePresence initial={false}>
|
|
{activeSession.messages.map((msg) => {
|
|
const isUser = msg.role === 'user';
|
|
return (
|
|
<motion.div
|
|
key={msg.id}
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.22, ease: 'easeOut' }}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
gap: { xs: 1.25, md: 1.5 },
|
|
flexDirection: isUser ? 'row-reverse' : 'row',
|
|
alignItems: 'flex-start',
|
|
}}
|
|
>
|
|
<Avatar
|
|
sx={{
|
|
bgcolor: isUser ? 'primary.main' : 'secondary.main',
|
|
color: 'white',
|
|
width: 30,
|
|
height: 30,
|
|
mt: 0.5,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{isUser ? <User size={15} /> : <Bot size={15} />}
|
|
</Avatar>
|
|
<Box
|
|
sx={{
|
|
maxWidth: { xs: '85%', md: '75%' },
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: isUser ? 'flex-end' : 'flex-start',
|
|
gap: 0.5,
|
|
}}
|
|
>
|
|
<Paper
|
|
elevation={0}
|
|
sx={{
|
|
px: { xs: 1.75, md: 2 },
|
|
py: { xs: 1.25, md: 1.5 },
|
|
borderRadius: 2.5,
|
|
borderTopRightRadius: isUser ? 6 : undefined,
|
|
borderTopLeftRadius: !isUser ? 6 : undefined,
|
|
bgcolor: isUser ? 'primary.main' : 'background.paper',
|
|
color: isUser ? 'white' : 'text.primary',
|
|
border: isUser ? 'none' : '1px solid',
|
|
borderColor: 'divider',
|
|
wordBreak: 'break-word',
|
|
}}
|
|
>
|
|
{msg.attachments && msg.attachments.length > 0 && (
|
|
<Box
|
|
sx={{
|
|
display: 'grid',
|
|
gridTemplateColumns:
|
|
msg.attachments.length === 1
|
|
? '1fr'
|
|
: 'repeat(auto-fill, minmax(120px, 1fr))',
|
|
gap: 0.75,
|
|
mb: msg.content ? 1 : 0,
|
|
}}
|
|
>
|
|
{msg.attachments.map((att, i) =>
|
|
att.type.startsWith('image/') ? (
|
|
<Box
|
|
key={i}
|
|
component="a"
|
|
href={att.dataUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
sx={{
|
|
display: 'block',
|
|
borderRadius: 1.5,
|
|
overflow: 'hidden',
|
|
border: '1px solid',
|
|
borderColor: isUser ? 'rgba(255,255,255,0.25)' : 'divider',
|
|
maxWidth: msg.attachments!.length === 1 ? 280 : 'unset',
|
|
}}
|
|
>
|
|
<Box
|
|
component="img"
|
|
src={att.dataUrl}
|
|
alt={att.name}
|
|
sx={{
|
|
width: '100%',
|
|
height: 'auto',
|
|
display: 'block',
|
|
maxHeight: 260,
|
|
objectFit: 'cover',
|
|
}}
|
|
/>
|
|
</Box>
|
|
) : (
|
|
<Box
|
|
key={i}
|
|
sx={{
|
|
p: 1,
|
|
borderRadius: 1.5,
|
|
border: '1px solid',
|
|
borderColor: isUser ? 'rgba(255,255,255,0.25)' : 'divider',
|
|
fontSize: '0.75rem',
|
|
fontWeight: 600,
|
|
bgcolor: isUser ? 'rgba(255,255,255,0.1)' : 'action.hover',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 0.75,
|
|
}}
|
|
>
|
|
📎 {att.name}
|
|
</Box>
|
|
)
|
|
)}
|
|
</Box>
|
|
)}
|
|
{msg.content &&
|
|
(isUser ? (
|
|
<Typography
|
|
variant="body1"
|
|
sx={{ fontSize: '0.95rem', lineHeight: 1.55, whiteSpace: 'pre-wrap' }}
|
|
>
|
|
{msg.content}
|
|
</Typography>
|
|
) : (
|
|
<MarkdownMessage content={msg.content} />
|
|
))}
|
|
</Paper>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 0.5,
|
|
px: 0.25,
|
|
opacity: 0.7,
|
|
}}
|
|
>
|
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
|
{formatTime(msg.timestamp)}
|
|
</Typography>
|
|
{!isUser && (
|
|
<Tooltip title={copiedId === msg.id ? 'Kopiert!' : 'Kopieren'}>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => handleCopy(msg.id, msg.content)}
|
|
sx={{ p: 0.25, color: 'text.secondary' }}
|
|
>
|
|
{copiedId === msg.id ? <Check size={12} /> : <Copy size={12} />}
|
|
</IconButton>
|
|
</Tooltip>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</AnimatePresence>
|
|
|
|
{isLoading && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
|
|
<Avatar
|
|
sx={{
|
|
bgcolor: 'secondary.main',
|
|
color: 'white',
|
|
width: 30,
|
|
height: 30,
|
|
mt: 0.5,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<Bot size={15} />
|
|
</Avatar>
|
|
<Paper
|
|
elevation={0}
|
|
sx={{
|
|
px: 2,
|
|
py: 1.5,
|
|
borderRadius: 2.5,
|
|
borderTopLeftRadius: 6,
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
bgcolor: 'background.paper',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
color: 'text.secondary',
|
|
}}
|
|
>
|
|
<Box component="span" className="pp-typing">
|
|
<span /> <span /> <span />
|
|
</Box>
|
|
</Paper>
|
|
</Box>
|
|
</motion.div>
|
|
)}
|
|
|
|
<div ref={bottomRef} />
|
|
</Box>
|
|
|
|
{/* Scroll-to-bottom */}
|
|
<Zoom in={showScrollDown}>
|
|
<Fab
|
|
size="small"
|
|
color="primary"
|
|
onClick={scrollToBottom}
|
|
sx={{
|
|
position: 'absolute',
|
|
bottom: { xs: 140, md: 150 },
|
|
right: { xs: 16, md: 28 },
|
|
boxShadow: 'var(--pp-shadow-brand)',
|
|
}}
|
|
aria-label="Zum Ende scrollen"
|
|
>
|
|
<ChevronDown size={18} />
|
|
</Fab>
|
|
</Zoom>
|
|
|
|
{/* Footer */}
|
|
<Box
|
|
sx={{
|
|
px: { xs: 2, md: 4 },
|
|
pt: 1.5,
|
|
pb: { xs: 2, md: 2.5 },
|
|
borderTop: '1px solid',
|
|
borderColor: 'divider',
|
|
bgcolor: 'background.paper',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{isEmpty && (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
gap: 1,
|
|
mb: 1.5,
|
|
maxWidth: 760,
|
|
mx: 'auto',
|
|
}}
|
|
>
|
|
{SUGGESTED_PROMPTS.map((p) => (
|
|
<Chip
|
|
key={p.text}
|
|
onClick={() => handleSuggested(p.text)}
|
|
clickable
|
|
variant="outlined"
|
|
label={`${p.icon} ${p.text}`}
|
|
sx={{
|
|
borderRadius: 2,
|
|
height: 32,
|
|
fontSize: '0.78rem',
|
|
borderStyle: 'dashed',
|
|
'&:hover': {
|
|
borderColor: 'primary.main',
|
|
bgcolor: 'action.hover',
|
|
borderStyle: 'solid',
|
|
},
|
|
}}
|
|
/>
|
|
))}
|
|
</Box>
|
|
)}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'flex-end',
|
|
gap: 1,
|
|
maxWidth: 760,
|
|
mx: 'auto',
|
|
}}
|
|
>
|
|
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
|
<AIInput onSend={handleSend} isLoading={isLoading} compact />
|
|
</Box>
|
|
{!isMobile && (
|
|
<Tooltip title="Letzte Anfrage erneut senden">
|
|
<span>
|
|
<IconButton
|
|
onClick={handleRetry}
|
|
disabled={isLoading || activeSession.messages.length === 0}
|
|
sx={{
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
borderRadius: 2,
|
|
width: 40,
|
|
height: 40,
|
|
}}
|
|
>
|
|
<RotateCcw size={16} />
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function EmptyState({ onPick }: { onPick: (text: string) => void }) {
|
|
return (
|
|
<Box
|
|
sx={{
|
|
flexGrow: 1,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
p: { xs: 3, md: 6 },
|
|
textAlign: 'center',
|
|
bgcolor: 'background.default',
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<Box sx={{ mb: 2.5 }}>
|
|
<Logo size={72} />
|
|
</Box>
|
|
<Typography variant="h5" sx={{ fontWeight: 800, mb: 1, letterSpacing: '-0.01em' }}>
|
|
Womit darf ich helfen?
|
|
</Typography>
|
|
<Typography color="text.secondary" sx={{ maxWidth: 460, mb: 4, lineHeight: 1.55 }}>
|
|
Frag mich nach Werkzeugen, Reparatur-Tipps oder beschreibe dein Problem.
|
|
</Typography>
|
|
<Box
|
|
sx={{
|
|
display: 'grid',
|
|
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)' },
|
|
gap: 1.5,
|
|
width: '100%',
|
|
maxWidth: 640,
|
|
}}
|
|
>
|
|
{SUGGESTED_PROMPTS.map((p) => (
|
|
<Paper
|
|
key={p.text}
|
|
variant="outlined"
|
|
onClick={() => onPick(p.text)}
|
|
sx={{
|
|
p: 2,
|
|
cursor: 'pointer',
|
|
textAlign: 'left',
|
|
borderRadius: 2.5,
|
|
transition: 'all 0.2s',
|
|
borderStyle: 'dashed',
|
|
'&:hover': {
|
|
borderColor: 'primary.main',
|
|
bgcolor: 'action.hover',
|
|
transform: 'translateY(-2px)',
|
|
borderStyle: 'solid',
|
|
},
|
|
}}
|
|
>
|
|
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.88rem' }}>
|
|
{p.icon} {p.text}
|
|
</Typography>
|
|
</Paper>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function ModeBadge({ mode }: { mode: ChatApiResponse['mode'] | null }) {
|
|
if (!mode) return null;
|
|
if (mode === 'live') {
|
|
return (
|
|
<Chip
|
|
size="small"
|
|
label="Live"
|
|
sx={{
|
|
bgcolor: 'success.main',
|
|
color: 'white',
|
|
fontWeight: 700,
|
|
fontSize: '0.65rem',
|
|
height: 22,
|
|
letterSpacing: '0.05em',
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
if (mode === 'mock-fallback') {
|
|
return (
|
|
<Tooltip title="Live-API nicht erreichbar, Fallback aktiv">
|
|
<Chip
|
|
size="small"
|
|
icon={<AlertCircle size={11} />}
|
|
label="Fallback"
|
|
sx={{
|
|
bgcolor: 'warning.main',
|
|
color: 'white',
|
|
fontWeight: 700,
|
|
fontSize: '0.65rem',
|
|
height: 22,
|
|
}}
|
|
/>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
return (
|
|
<Chip
|
|
size="small"
|
|
label="Demo"
|
|
variant="outlined"
|
|
sx={{ fontWeight: 700, fontSize: '0.65rem', height: 22 }}
|
|
/>
|
|
);
|
|
}
|