feat: initial commit
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { Wand2 } from 'lucide-react'
|
||||
import type { Material } from '../../api/materials'
|
||||
|
||||
const TYPE_GROUPS: Record<string, { label: string; color: string }> = {
|
||||
'01': { label: 'Metals', color: 'text-slate-500' },
|
||||
'02': { label: 'Coatings', color: 'text-blue-500' },
|
||||
'03': { label: 'Non-metals', color: 'text-amber-600' },
|
||||
'04': { label: 'Compounds', color: 'text-purple-500' },
|
||||
'05': { label: 'Misc', color: 'text-content-muted' },
|
||||
}
|
||||
|
||||
function getTypeCode(mat: Material): string | null {
|
||||
if (mat.schaeffler_code == null) return null
|
||||
const s = String(mat.schaeffler_code).padStart(6, '0')
|
||||
return s.slice(0, 2)
|
||||
}
|
||||
|
||||
/** Extract the human-readable short name after the last underscore: SCHAEFFLER_010101_Steel-Bare -> Steel-Bare */
|
||||
function shortName(name: string): string {
|
||||
const match = name.match(/^SCHAEFFLER_\d{6}_(.+)$/)
|
||||
return match ? match[1].replace(/-/g, ' ') : name
|
||||
}
|
||||
|
||||
export interface MaterialInputProps {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
library: Material[]
|
||||
missing: boolean
|
||||
onOpenWizard: () => void
|
||||
}
|
||||
|
||||
export default function MaterialInput({ value, onChange, library, missing, onOpenWizard }: MaterialInputProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const trimmed = value.trim()
|
||||
const suggestions = trimmed
|
||||
? library.filter((m) => m.name.toLowerCase().includes(trimmed.toLowerCase())
|
||||
|| shortName(m.name).toLowerCase().includes(trimmed.toLowerCase())
|
||||
|| (m.description ?? '').toLowerCase().includes(trimmed.toLowerCase()))
|
||||
: library
|
||||
|
||||
// Group suggestions by type code
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Array<{ code: string | null; label: string; color: string; items: Material[] }> = []
|
||||
const buckets = new Map<string | null, Material[]>()
|
||||
|
||||
for (const m of suggestions) {
|
||||
const tc = getTypeCode(m)
|
||||
if (!buckets.has(tc)) buckets.set(tc, [])
|
||||
buckets.get(tc)!.push(m)
|
||||
}
|
||||
|
||||
// Sorted type codes first, then non-schaeffler
|
||||
const sortedKeys = [...buckets.keys()].sort((a, b) => {
|
||||
if (a === null) return 1
|
||||
if (b === null) return -1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const info = key ? TYPE_GROUPS[key] : null
|
||||
groups.push({
|
||||
code: key,
|
||||
label: info?.label ?? 'Custom',
|
||||
color: info?.color ?? 'text-content-muted',
|
||||
items: buckets.get(key)!,
|
||||
})
|
||||
}
|
||||
return groups
|
||||
}, [suggestions])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
const select = (name: string) => {
|
||||
onChange(name)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => { onChange(e.target.value); setOpen(true) }}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder={missing ? 'Required — assign a material' : 'Search materials...'}
|
||||
className={`w-full px-2 py-1 text-sm border rounded focus:outline-none bg-surface ${
|
||||
missing
|
||||
? 'border-red-300 focus:border-red-500 placeholder-red-300'
|
||||
: 'border-border-default focus:border-accent'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{open && (suggestions.length > 0 || true) && (
|
||||
<div className="absolute left-0 top-full mt-0.5 w-80 border border-border-default rounded-lg shadow-xl z-50 max-h-64 overflow-y-auto" style={{ backgroundColor: 'var(--color-bg-surface)' }}>
|
||||
{grouped.map((group) => (
|
||||
<div key={group.code ?? 'custom'}>
|
||||
{/* Group header */}
|
||||
<div className="sticky top-0 px-3 py-1 border-b border-border-light" style={{ backgroundColor: 'var(--color-bg-app)' }}>
|
||||
<span className={`text-[10px] font-bold uppercase tracking-wider ${group.color}`}>
|
||||
{group.code ? `${group.code} ` : ''}{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.items.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onMouseDown={(e) => { e.preventDefault(); select(m.name) }}
|
||||
className="w-full text-left px-3 py-1.5 hover:bg-accent-light flex items-baseline gap-2"
|
||||
>
|
||||
<span className="text-sm font-medium text-content truncate">{shortName(m.name)}</span>
|
||||
{m.description && (
|
||||
<span className="text-xs text-content-muted truncate">{m.description}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{suggestions.length === 0 && (
|
||||
<div className="px-3 py-3 text-center text-xs text-content-muted">No materials match "{trimmed}"</div>
|
||||
)}
|
||||
|
||||
{/* Create new material via wizard */}
|
||||
<button
|
||||
onMouseDown={(e) => { e.preventDefault(); setOpen(false); onOpenWizard() }}
|
||||
className="w-full text-left px-3 py-2 border-t border-border-default flex items-center gap-2 hover:bg-surface-hover text-accent sticky bottom-0"
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||
>
|
||||
<Wand2 size={13} />
|
||||
<span className="text-sm font-medium">Create new material...</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '../../utils/format'
|
||||
|
||||
interface ModalProps {
|
||||
title: string
|
||||
onClose: () => void
|
||||
children: React.ReactNode
|
||||
/** Extra classes applied to the inner panel */
|
||||
className?: string
|
||||
/** Width preset – defaults to 'md' */
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
||||
}
|
||||
|
||||
const sizeMap: Record<NonNullable<ModalProps['size']>, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
full: 'max-w-full mx-4',
|
||||
}
|
||||
|
||||
export default function Modal({ title, onClose, children, className, size = 'md' }: ModalProps) {
|
||||
const backdropRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
/* Close on Escape */
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [onClose])
|
||||
|
||||
/* Prevent scroll on body while modal is open */
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [])
|
||||
|
||||
function handleBackdropClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||
if (e.target === backdropRef.current) onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={backdropRef}
|
||||
onClick={handleBackdropClick}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full rounded-xl shadow-2xl flex flex-col max-h-[90vh]',
|
||||
sizeMap[size],
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border-default shrink-0">
|
||||
<h2 id="modal-title" className="text-lg font-semibold text-content">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md text-content-muted hover:text-content-secondary hover:bg-surface-muted transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user