244 lines
11 KiB
TypeScript
244 lines
11 KiB
TypeScript
|
|
import React, { useEffect, useState } from 'react'
|
|||
|
|
|
|||
|
|
// ── Empty state ───────────────────────────────────────────────────────────────
|
|||
|
|
export function EmptyState({ icon, title, message, action }: {
|
|||
|
|
icon?: string; title: string; message?: string
|
|||
|
|
action?: { label: string; onClick: () => void }
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<div style={{ textAlign: 'center', padding: '48px 24px', color: '#aaa' }}>
|
|||
|
|
{icon && <div style={{ fontSize: '32px', marginBottom: '10px', opacity: 0.4 }}>{icon}</div>}
|
|||
|
|
<div style={{ fontSize: '14px', fontWeight: '500', color: '#666', marginBottom: '6px' }}>{title}</div>
|
|||
|
|
{message && <div style={{ fontSize: '12px', color: '#aaa', marginBottom: '16px', maxWidth: '300px', margin: '0 auto 16px' }}>{message}</div>}
|
|||
|
|
{action && (
|
|||
|
|
<button onClick={action.onClick} style={{
|
|||
|
|
padding: '8px 16px', fontSize: '12px', fontWeight: '500',
|
|||
|
|
background: '#534AB7', color: 'white', border: 'none',
|
|||
|
|
borderRadius: '8px', cursor: 'pointer'
|
|||
|
|
}}>{action.label}</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Stat card ─────────────────────────────────────────────────────────────────
|
|||
|
|
export function StatCard({ label, value, sub, color }: {
|
|||
|
|
label: string; value: string | number | null; sub?: string; color?: string
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<div style={{
|
|||
|
|
background: 'white', border: '0.5px solid #eee',
|
|||
|
|
borderRadius: '10px', padding: '12px 14px'
|
|||
|
|
}}>
|
|||
|
|
<div style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '0.6px', color: '#aaa', marginBottom: '4px' }}>
|
|||
|
|
{label}
|
|||
|
|
</div>
|
|||
|
|
<div style={{ fontSize: '24px', fontWeight: '500', color: color || '#1a1a1a', lineHeight: '1.1' }}>
|
|||
|
|
{value === null ? '—' : value}
|
|||
|
|
</div>
|
|||
|
|
{sub && <div style={{ fontSize: '10px', color: '#aaa', marginTop: '3px' }}>{sub}</div>}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Tag / badge ───────────────────────────────────────────────────────────────
|
|||
|
|
const TAG_STYLES: Record<string, { bg: string; color: string }> = {
|
|||
|
|
green: { bg: '#EAF3DE', color: '#27500A' },
|
|||
|
|
amber: { bg: '#FAEEDA', color: '#633806' },
|
|||
|
|
red: { bg: '#FCEBEB', color: '#791F1F' },
|
|||
|
|
blue: { bg: '#E6F1FB', color: '#0C447C' },
|
|||
|
|
purple: { bg: '#EEEDFE', color: '#3C3489' },
|
|||
|
|
gray: { bg: '#F1EFE8', color: '#444441' },
|
|||
|
|
teal: { bg: '#E1F5EE', color: '#085041' },
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function Tag({ children, color = 'gray' }: { children: React.ReactNode; color?: string }) {
|
|||
|
|
const s = TAG_STYLES[color] || TAG_STYLES.gray
|
|||
|
|
return (
|
|||
|
|
<span style={{
|
|||
|
|
fontSize: '10px', padding: '2px 7px', borderRadius: '9px',
|
|||
|
|
fontWeight: '500', background: s.bg, color: s.color,
|
|||
|
|
whiteSpace: 'nowrap', display: 'inline-block'
|
|||
|
|
}}>
|
|||
|
|
{children}
|
|||
|
|
</span>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Status dot ────────────────────────────────────────────────────────────────
|
|||
|
|
const STATUS_COLORS: Record<string, string> = {
|
|||
|
|
OPEN: '#378ADD', IN_PROGRESS: '#EF9F27', OVERDUE: '#E24B4A',
|
|||
|
|
CLOSED: '#1D9E75', SCHEDULED: '#378ADD', COMPLETED: '#1D9E75',
|
|||
|
|
CANCELLED: '#aaa', INVESTIGATING: '#EF9F27', ESCALATED: '#E24B4A',
|
|||
|
|
RESOLVED: '#1D9E75', APPROVED: '#1D9E75', UNDER_REVIEW: '#EF9F27',
|
|||
|
|
SUSPENDED: '#E24B4A', DRAFT: '#aaa', ACTIVE: '#1D9E75',
|
|||
|
|
REVIEW_READY: '#EF9F27', STANDARD_SET: '#1D9E75', ARCHIVED: '#aaa',
|
|||
|
|
CURRENT: '#1D9E75', PENDING_REVIEW: '#EF9F27', EXPIRED: '#E24B4A',
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function StatusDot({ status }: { status: string }) {
|
|||
|
|
return (
|
|||
|
|
<span style={{
|
|||
|
|
width: '6px', height: '6px', borderRadius: '50%',
|
|||
|
|
background: STATUS_COLORS[status] || '#aaa',
|
|||
|
|
display: 'inline-block', marginRight: '5px', flexShrink: 0, verticalAlign: 'middle'
|
|||
|
|
}}/>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Card ──────────────────────────────────────────────────────────────────────
|
|||
|
|
export function Card({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
|
|||
|
|
return (
|
|||
|
|
<div style={{
|
|||
|
|
background: 'white', border: '0.5px solid #eee',
|
|||
|
|
borderRadius: '12px', padding: '14px 16px',
|
|||
|
|
...style
|
|||
|
|
}}>
|
|||
|
|
{children}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Table ─────────────────────────────────────────────────────────────────────
|
|||
|
|
export function Table({ headers, children, empty }: {
|
|||
|
|
headers: string[]; children: React.ReactNode; empty?: boolean
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '12px' }}>
|
|||
|
|
<thead>
|
|||
|
|
<tr>
|
|||
|
|
{headers.map(h => (
|
|||
|
|
<th key={h} style={{
|
|||
|
|
textAlign: 'left', fontWeight: '500', fontSize: '10px',
|
|||
|
|
textTransform: 'uppercase', letterSpacing: '0.5px',
|
|||
|
|
color: '#aaa', padding: '5px 6px',
|
|||
|
|
borderBottom: '0.5px solid #eee'
|
|||
|
|
}}>{h}</th>
|
|||
|
|
))}
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>{children}</tbody>
|
|||
|
|
</table>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Toast ─────────────────────────────────────────────────────────────────────
|
|||
|
|
type ToastType = 'success' | 'error' | 'info'
|
|||
|
|
let toastCallback: ((msg: string, type: ToastType) => void) | null = null
|
|||
|
|
|
|||
|
|
export function showToast(msg: string, type: ToastType = 'success') {
|
|||
|
|
toastCallback?.(msg, type)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function ToastProvider() {
|
|||
|
|
const [toasts, setToasts] = useState<{ id: number; msg: string; type: ToastType }[]>([])
|
|||
|
|
let counter = 0
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
toastCallback = (msg, type) => {
|
|||
|
|
const id = ++counter
|
|||
|
|
setToasts(t => [...t, { id, msg, type }])
|
|||
|
|
setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 3500)
|
|||
|
|
}
|
|||
|
|
return () => { toastCallback = null }
|
|||
|
|
}, [])
|
|||
|
|
|
|||
|
|
const colors = { success: '#1D9E75', error: '#E24B4A', info: '#534AB7' }
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div style={{ position: 'fixed', bottom: '20px', right: '20px', zIndex: 1000, display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|||
|
|
{toasts.map(t => (
|
|||
|
|
<div key={t.id} className="toast-enter" style={{
|
|||
|
|
background: 'white', border: `0.5px solid ${colors[t.type]}33`,
|
|||
|
|
borderLeft: `3px solid ${colors[t.type]}`,
|
|||
|
|
borderRadius: '8px', padding: '10px 14px',
|
|||
|
|
fontSize: '12px', color: '#333',
|
|||
|
|
minWidth: '220px', maxWidth: '320px',
|
|||
|
|
}}>
|
|||
|
|
{t.msg}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Button ────────────────────────────────────────────────────────────────────
|
|||
|
|
export function Btn({ children, onClick, variant = 'primary', size = 'md', disabled, type = 'button', style }: {
|
|||
|
|
children: React.ReactNode; onClick?: () => void
|
|||
|
|
variant?: 'primary' | 'ghost' | 'danger'; size?: 'sm' | 'md'
|
|||
|
|
disabled?: boolean; type?: 'button' | 'submit'; style?: React.CSSProperties
|
|||
|
|
}) {
|
|||
|
|
const base: React.CSSProperties = {
|
|||
|
|
display: 'inline-flex', alignItems: 'center', gap: '5px',
|
|||
|
|
border: 'none', cursor: disabled ? 'not-allowed' : 'pointer',
|
|||
|
|
fontFamily: 'inherit', fontWeight: '500', transition: 'all 0.1s',
|
|||
|
|
opacity: disabled ? 0.6 : 1,
|
|||
|
|
padding: size === 'sm' ? '5px 10px' : '7px 14px',
|
|||
|
|
fontSize: size === 'sm' ? '11px' : '12px',
|
|||
|
|
borderRadius: '8px',
|
|||
|
|
}
|
|||
|
|
const variants = {
|
|||
|
|
primary: { background: '#534AB7', color: 'white', border: 'none' },
|
|||
|
|
ghost: { background: 'transparent', color: '#444', border: '0.5px solid #ddd' },
|
|||
|
|
danger: { background: '#FCEBEB', color: '#791F1F', border: '0.5px solid #F09595' },
|
|||
|
|
}
|
|||
|
|
return (
|
|||
|
|
<button type={type} onClick={onClick} disabled={disabled} style={{ ...base, ...variants[variant], ...style }}>
|
|||
|
|
{children}
|
|||
|
|
</button>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Modal ─────────────────────────────────────────────────────────────────────
|
|||
|
|
export function Modal({ open, onClose, title, children, width = 400 }: {
|
|||
|
|
open: boolean; onClose: () => void; title: string
|
|||
|
|
children: React.ReactNode; width?: number
|
|||
|
|
}) {
|
|||
|
|
if (!open) return null
|
|||
|
|
return (
|
|||
|
|
<div style={{
|
|||
|
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.3)',
|
|||
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|||
|
|
zIndex: 500, padding: '20px'
|
|||
|
|
}} onClick={e => e.target === e.currentTarget && onClose()}>
|
|||
|
|
<div style={{
|
|||
|
|
background: 'white', borderRadius: '12px',
|
|||
|
|
border: '0.5px solid #e0e0e0',
|
|||
|
|
padding: '20px', width: '100%', maxWidth: `${width}px`
|
|||
|
|
}}>
|
|||
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
|
|||
|
|
<div style={{ fontSize: '14px', fontWeight: '500' }}>{title}</div>
|
|||
|
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#aaa', fontSize: '18px' }}>×</button>
|
|||
|
|
</div>
|
|||
|
|
{children}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Form helpers ──────────────────────────────────────────────────────────────
|
|||
|
|
export function Field({ label, children, required }: { label: string; children: React.ReactNode; required?: boolean }) {
|
|||
|
|
return (
|
|||
|
|
<div style={{ marginBottom: '12px' }}>
|
|||
|
|
<label style={{ display: 'block', fontSize: '11px', color: '#666', marginBottom: '4px', fontWeight: '500' }}>
|
|||
|
|
{label}{required && <span style={{ color: '#E24B4A', marginLeft: '2px' }}>*</span>}
|
|||
|
|
</label>
|
|||
|
|
{children}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const inputStyle: React.CSSProperties = {
|
|||
|
|
width: '100%', padding: '7px 10px', fontSize: '12px',
|
|||
|
|
border: '0.5px solid #ddd', borderRadius: '8px',
|
|||
|
|
background: 'transparent', color: '#1a1a1a', fontFamily: 'inherit', outline: 'none'
|
|||
|
|
}
|
|||
|
|
export const Input = (props: React.InputHTMLAttributes<HTMLInputElement>) =>
|
|||
|
|
<input {...props} style={{ ...inputStyle, ...props.style }}/>
|
|||
|
|
|
|||
|
|
export const Select = (props: React.SelectHTMLAttributes<HTMLSelectElement>) =>
|
|||
|
|
<select {...props} style={{ ...inputStyle, background: 'white', ...props.style }}/>
|
|||
|
|
|
|||
|
|
export const Textarea = (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) =>
|
|||
|
|
<textarea {...props} style={{ ...inputStyle, resize: 'vertical', minHeight: '70px', lineHeight: '1.5', ...props.style }}/>
|