361 lines
18 KiB
TypeScript
361 lines
18 KiB
TypeScript
import React, { useState, ReactNode } from 'react';
|
|
import { NavLink, Outlet } from 'react-router-dom';
|
|
import { useTheme } from '../contexts/ThemeContext';
|
|
import { Theme, NavItem } from '../types';
|
|
import { useOrg } from '../contexts/OrgContext';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
|
|
// ========== ICONS ==========
|
|
|
|
export const CompanyWikiIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 22h16a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1zM8 7h8v2H8V7zm0 4h8v2H8v-2zm0 4h5v2H8v-2z" fill="currentColor" /></svg>
|
|
);
|
|
export const SubmissionsIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM9 13v-2h6v2H9zm6 4H9v-2h6v2zm-6-8V4h1l5 5h-1V9H9z" fill="currentColor" /></svg>
|
|
);
|
|
export const ReportsIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16 6h3a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h3V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2zm-4-2v2h-2V4h2zm-2 9v-2h-2v2h2zm4 0v-2h-2v2h2zm-4 4v-2h-2v2h2zm4 0v-2h-2v2h2zm-6-4H8V9h2v2z" fill="currentColor" /></svg>
|
|
);
|
|
export const ChatIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M21 11.5a8.5 8.5 0 0 1-17 0 8.5 8.5 0 0 1 17 0z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /><path d="M21 11.5a8.5 8.5 0 0 0-8.5-8.5V3m0 17v-1.5a8.5 8.5 0 0 0 8.5-8.5H11" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
|
);
|
|
export const HelpIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-13a1 1 0 0 0-1 1v4a1 1 0 0 0 2 0v-4a1 1 0 0 0-1-1zm0 8a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" fill="currentColor" /></svg>
|
|
);
|
|
export const SettingsIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19.14 12.94a2.001 2.001 0 0 0-2.28 0l-.71.71a2 2 0 0 0 0 2.83l.71.71a2 2 0 0 0 2.83 0l.71-.71a2 2 0 0 0 0-2.83l-.71-.71zM4.86 12.94a2 2 0 0 0 0 2.83l.71.71a2 2 0 0 0 2.83 0l.71-.71a2 2 0 0 0 0-2.83l-.71-.71a2 2 0 0 0-2.83 0zM12 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm7.78-1.94a2 2 0 0 0-2.83 0l-.71.71a2 2 0 0 0 0 2.83l.71.71a2 2 0 0 0 2.83 0l.71-.71a2 2 0 0 0 0-2.83l-.71-.71zM4.86 5.22a2 2 0 0 0 0 2.83l.71.71a2 2 0 0 0 2.83 0l.71-.71a2 2 0 0 0 0-2.83l-.71-.71a2 2 0 0 0-2.83 0z" fill="currentColor" /></svg>
|
|
);
|
|
export const CopyIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16 2H8a2 2 0 0 0-2 2v12h2V4h8v4h4v8h2V8l-4-4h-2z" fill="currentColor" /><path d="M4 8h12v12H4z" fill="currentColor" opacity="0.5" /></svg>
|
|
);
|
|
export const PlusIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" fill="currentColor" /></svg>
|
|
);
|
|
export const ChevronDownIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 15.5l-6-6 1.4-1.4 4.6 4.6 4.6-4.6L18 9.5l-6 6z" fill="currentColor" /></svg>
|
|
);
|
|
export const UploadIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9 16h6v-6h4l-8-8-8 8h4v6zm-4 2h14v2H5v-2z" fill="currentColor" /></svg>
|
|
);
|
|
export const CheckIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="currentColor" /></svg>
|
|
);
|
|
export const WarningIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2V7h2v7z" fill="currentColor" /></svg>
|
|
);
|
|
export const DownloadIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" fill="currentColor" /></svg>
|
|
);
|
|
export const MinusIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19 13H5v-2h14v2z" fill="currentColor" /></svg>
|
|
);
|
|
export const SunIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
|
|
);
|
|
export const MoonIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
|
|
);
|
|
export const SystemIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
|
|
);
|
|
export const SendIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" /></svg>
|
|
);
|
|
|
|
const NAV_ITEMS: NavItem[] = [
|
|
{ href: '/company-wiki', label: 'Company Wiki', icon: CompanyWikiIcon },
|
|
{ href: '/submissions', label: 'Submissions', icon: SubmissionsIcon },
|
|
{ href: '/reports', label: 'Reports', icon: ReportsIcon },
|
|
{ href: '/chat', label: 'Chat', icon: ChatIcon },
|
|
{ href: '/help', label: 'Help', icon: HelpIcon },
|
|
];
|
|
|
|
const BOTTOM_NAV_ITEMS: NavItem[] = [
|
|
{ href: '/settings', label: 'Settings', icon: SettingsIcon }
|
|
];
|
|
|
|
|
|
// ========== LAYOUT COMPONENTS ==========
|
|
|
|
const Sidebar = () => {
|
|
const { org, issueInviteViaApi } = useOrg();
|
|
const { signOutUser } = useAuth();
|
|
const [showInviteModal, setShowInviteModal] = useState(false);
|
|
const [inviteForm, setInviteForm] = useState({ name: '', email: '', role: '', department: '' });
|
|
const [inviteLink, setInviteLink] = useState('');
|
|
const [emailLink, setEmailLink] = useState('');
|
|
|
|
const commonLinkClasses = "flex items-center w-full px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-200";
|
|
const activeClass = "bg-[--sidebar-active-bg] text-[--sidebar-active-text]";
|
|
const inactiveClass = "text-[--sidebar-text] hover:bg-[--sidebar-active-bg] hover:text-[--sidebar-active-text]";
|
|
|
|
const handleInvite = async () => {
|
|
try {
|
|
const result = await issueInviteViaApi({
|
|
name: inviteForm.name,
|
|
email: inviteForm.email,
|
|
role: inviteForm.role,
|
|
department: inviteForm.department
|
|
});
|
|
setInviteLink(result.inviteLink);
|
|
setEmailLink(result.emailLink);
|
|
setInviteForm({ name: '', email: '', role: '', department: '' });
|
|
} catch (error) {
|
|
console.error('Failed to invite employee:', error);
|
|
}
|
|
};
|
|
|
|
const copyToClipboard = (text: string) => {
|
|
navigator.clipboard.writeText(text);
|
|
};
|
|
|
|
const renderNavItems = (items: NavItem[]) => items.map(item => (
|
|
<li key={item.href}>
|
|
<NavLink
|
|
to={item.href}
|
|
className={({ isActive }) => `${commonLinkClasses} ${isActive ? activeClass : inactiveClass}`}
|
|
>
|
|
<item.icon className="w-5 h-5 mr-3" />
|
|
<span>{item.label}</span>
|
|
</NavLink>
|
|
</li>
|
|
));
|
|
|
|
return (
|
|
<aside className="w-64 flex-shrink-0 bg-[--sidebar-bg] border-r border-[--border-color] flex flex-col p-4">
|
|
<div className="flex items-center mb-8">
|
|
<div className="w-8 h-8 bg-[--accent] rounded-full flex items-center justify-center font-bold text-white text-lg mr-2">A</div>
|
|
<h1 className="text-xl font-bold text-[--text-primary]">{org?.name || 'Auditly'}</h1>
|
|
</div>
|
|
<nav className="flex-1">
|
|
<ul className="space-y-2">
|
|
{renderNavItems(NAV_ITEMS)}
|
|
</ul>
|
|
</nav>
|
|
<div className="mt-auto space-y-4">
|
|
<ul className="space-y-2 border-t border-[--border-color] pt-4">
|
|
{renderNavItems(BOTTOM_NAV_ITEMS)}
|
|
</ul>
|
|
<div className="p-4 rounded-lg bg-[--background-tertiary] text-center">
|
|
<h3 className="font-bold text-sm text-[--text-primary]">Build [Company]'s Report</h3>
|
|
<p className="text-xs text-[--text-secondary] mt-1 mb-3">Share this form with your team members to capture valuable info about your company to train Auditly.</p>
|
|
<div className="flex space-x-2">
|
|
<Button size="sm" className="w-full" onClick={() => setShowInviteModal(true)}>
|
|
<PlusIcon className="w-4 h-4 mr-1" /> Invite
|
|
</Button>
|
|
<Button size="sm" variant="secondary" className="w-full" onClick={() => (emailLink || inviteLink) && copyToClipboard(emailLink || inviteLink)}>
|
|
<CopyIcon className="w-4 h-4 mr-1" /> Copy
|
|
</Button>
|
|
</div>
|
|
<Button size="sm" variant="ghost" className="w-full mt-2" onClick={signOutUser}>Sign out</Button>
|
|
</div>
|
|
|
|
{/* Invite Modal */}
|
|
{showInviteModal && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-[--background-secondary] p-6 rounded-lg max-w-md w-full mx-4">
|
|
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Invite Employee</h3>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-[--text-primary] mb-2">Name</label>
|
|
<input
|
|
type="text"
|
|
value={inviteForm.name}
|
|
onChange={(e) => setInviteForm(prev => ({ ...prev, name: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
placeholder="Employee name"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[--text-primary] mb-2">Email</label>
|
|
<input
|
|
type="email"
|
|
value={inviteForm.email}
|
|
onChange={(e) => setInviteForm(prev => ({ ...prev, email: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
placeholder="employee@company.com"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[--text-primary] mb-2">Role</label>
|
|
<input
|
|
type="text"
|
|
value={inviteForm.role}
|
|
onChange={(e) => setInviteForm(prev => ({ ...prev, role: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
placeholder="e.g. Software Engineer"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[--text-primary] mb-2">Department</label>
|
|
<input
|
|
type="text"
|
|
value={inviteForm.department}
|
|
onChange={(e) => setInviteForm(prev => ({ ...prev, department: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
placeholder="e.g. Engineering"
|
|
/>
|
|
</div>
|
|
{(inviteLink || emailLink) && (
|
|
<div className="space-y-3">
|
|
{emailLink && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
|
Email Link (for GET requests)
|
|
</label>
|
|
<div className="flex space-x-2">
|
|
<input
|
|
type="text"
|
|
value={emailLink}
|
|
readOnly
|
|
className="flex-1 px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none text-sm"
|
|
/>
|
|
<Button size="sm" onClick={() => copyToClipboard(emailLink)}>
|
|
<CopyIcon className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{inviteLink && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
|
App Link (direct)
|
|
</label>
|
|
<div className="flex space-x-2">
|
|
<input
|
|
type="text"
|
|
value={inviteLink}
|
|
readOnly
|
|
className="flex-1 px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none text-sm"
|
|
/>
|
|
<Button size="sm" onClick={() => copyToClipboard(inviteLink)}>
|
|
<CopyIcon className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex space-x-2 mt-6">
|
|
<Button
|
|
variant="secondary"
|
|
className="flex-1"
|
|
onClick={() => {
|
|
setShowInviteModal(false);
|
|
setInviteLink('');
|
|
setInviteForm({ name: '', email: '', role: '', department: '' });
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
className="flex-1"
|
|
onClick={handleInvite}
|
|
disabled={!inviteForm.name || !inviteForm.email}
|
|
>
|
|
Generate Invite
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
);
|
|
};
|
|
|
|
export const Layout = () => (
|
|
<div className="flex h-screen bg-[--background-primary]">
|
|
<Sidebar />
|
|
<main className="flex-1 overflow-y-auto">
|
|
<Outlet />
|
|
</main>
|
|
</div>
|
|
);
|
|
|
|
|
|
// ========== UI PRIMITIVES ==========
|
|
|
|
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
children: ReactNode;
|
|
className?: string;
|
|
padding?: 'sm' | 'md' | 'lg';
|
|
}
|
|
|
|
export const Card: React.FC<CardProps> = ({ children, className, padding = 'md', ...props }) => {
|
|
const paddingClasses = {
|
|
sm: 'p-4',
|
|
md: 'p-6',
|
|
lg: 'p-8',
|
|
};
|
|
return (
|
|
<div className={`bg-[--background-secondary] border border-[--border-color] rounded-xl shadow-sm ${paddingClasses[padding]} ${className || ''}`} {...props}>
|
|
{children}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
|
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
children: ReactNode;
|
|
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
|
size?: 'sm' | 'md' | 'lg';
|
|
}
|
|
|
|
export const Button: React.FC<ButtonProps> = ({ children, variant = 'primary', size = 'md', className, ...props }) => {
|
|
const baseClasses = 'inline-flex items-center justify-center font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed';
|
|
|
|
const variantClasses = {
|
|
primary: 'bg-[--accent] text-[--accent-text] hover:bg-[--accent-hover] focus:ring-[--accent]',
|
|
secondary: 'bg-[--button-secondary-bg] text-[--text-primary] hover:bg-[--button-secondary-hover] focus:ring-[--accent] border border-[--border-color]',
|
|
danger: 'bg-[--status-red] text-white hover:bg-red-700 focus:ring-red-500',
|
|
ghost: 'bg-transparent text-[--text-primary] hover:bg-[--background-tertiary]'
|
|
};
|
|
|
|
const sizeClasses = {
|
|
sm: 'px-3 py-1.5 text-xs',
|
|
md: 'px-4 py-2 text-sm',
|
|
lg: 'px-6 py-3 text-base',
|
|
};
|
|
|
|
return (
|
|
<button className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className || ''}`} {...props}>
|
|
{children}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
|
|
interface AccordionProps {
|
|
items: { question: string; answer: string }[];
|
|
}
|
|
|
|
export const Accordion = ({ items }: AccordionProps) => {
|
|
const [openIndex, setOpenIndex] = useState<number | null>(0);
|
|
|
|
const toggleItem = (index: number) => {
|
|
setOpenIndex(openIndex === index ? null : index);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{items.map((item, index) => (
|
|
<div key={index} className="border-b border-[--border-color]">
|
|
<button
|
|
onClick={() => toggleItem(index)}
|
|
className="w-full flex justify-between items-center py-4 text-left text-[--text-primary] font-medium"
|
|
>
|
|
<span>{item.question}</span>
|
|
{openIndex === index ? <MinusIcon className="w-5 h-5" /> : <PlusIcon className="w-5 h-5" />}
|
|
</button>
|
|
{openIndex === index && (
|
|
<div className="pb-4 text-[--text-secondary]">
|
|
{item.answer}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|