chore: import DH V2 D5.6.4 production baseline

This commit is contained in:
DH V2
2026-09-05 10:12:35 -03:00
commit 82213e72f5
757 changed files with 84218 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
import { ApiError } from '../lib/api';
export function errorMessage(error: unknown) {
return error instanceof ApiError ? error.message : 'No se pudo completar la operación';
}
export function LoadingBlock({ label = 'Cargando…' }: { label?: string }) {
return <div className="loading-block"><span className="spinner" />{label}</div>;
}
export function EmptyState({ title, text }: { title: string; text: string }) {
return <div className="empty-state"><strong>{title}</strong><p>{text}</p></div>;
}
export function Alert({ type = 'error', children }: { type?: 'error' | 'success' | 'info'; children: React.ReactNode }) {
return <div className={`alert-box ${type}`}>{children}</div>;
}
+33
View File
@@ -0,0 +1,33 @@
export type IconName =
| 'home' | 'map' | 'layers' | 'calendar' | 'clipboard' | 'alert'
| 'history' | 'users' | 'shield' | 'audit' | 'logout' | 'menu'
| 'plus' | 'search' | 'edit' | 'chevron' | 'check' | 'key' | 'upload';
export function Icon({ name, size = 18 }: { name: IconName; size?: number }) {
const paths: Record<IconName, React.ReactNode> = {
home: <><path d="m3 10 9-7 9 7"/><path d="M5 9v11h14V9"/><path d="M9 20v-6h6v6"/></>,
map: <><path d="m3 6 6-3 6 3 6-3v15l-6 3-6-3-6 3Z"/><path d="M9 3v15M15 6v15"/></>,
layers: <><path d="m12 3-9 5 9 5 9-5Z"/><path d="m3 12 9 5 9-5M3 16l9 5 9-5"/></>,
calendar: <><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M16 3v4M8 3v4M3 10h18"/></>,
clipboard: <><rect x="5" y="4" width="14" height="17" rx="2"/><path d="M9 4V2h6v2M9 12h6M9 16h5"/></>,
alert: <><path d="M12 3 2.5 20h19Z"/><path d="M12 9v5M12 17h.01"/></>,
history: <><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/></>,
users: <><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8"/></>,
shield: <><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/></>,
audit: <><path d="M4 4h16v16H4z"/><path d="M8 9h8M8 13h8M8 17h5M8 4V2M16 4V2"/></>,
logout: <><path d="M10 17l5-5-5-5M15 12H3"/><path d="M14 3h5a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-5"/></>,
menu: <><path d="M4 6h16M4 12h16M4 18h16"/></>,
plus: <><path d="M12 5v14M5 12h14"/></>,
search: <><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></>,
edit: <><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z"/></>,
chevron: <path d="m9 18 6-6-6-6"/>,
check: <path d="m5 12 4 4L19 6"/>,
key: <><circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M15 8l3 3M17 6l2 2"/></>,
upload: <><path d="M12 16V4"/><path d="m7 9 5-5 5 5"/><path d="M5 20h14"/></>,
};
return (
<svg className="icon" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{paths[name]}
</svg>
);
}
@@ -0,0 +1,91 @@
import { SearchableSelect } from './SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useNavigate } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { useOperationalContext } from '../context/OperationalContext';
import { listCompaniesForArea, listOperationalAreas } from '../lib/api';
import type { OperationalAssetSummary } from '../lib/api';
import { Icon } from './Icon';
export function OperationalContextBar() {
const navigate = useNavigate();
const { hasPermission } = useAuth();
const { areaId, companyId, setAreaId, setCompanyId, clearContext } = useOperationalContext();
const [areas, setAreas] = useState<OperationalAssetSummary[]>([]);
const [companies, setCompanies] = useState<OperationalAssetSummary[]>([]);
const [search, setSearch] = useState('');
const canReadInventory = hasPermission('assets.read');
const canReadInspections = hasPermission('inspections.read');
useEffect(() => {
if (!canReadInventory && !canReadInspections) return;
listOperationalAreas().then(setAreas).catch(() => setAreas([]));
}, [canReadInventory, canReadInspections]);
useEffect(() => {
if (!areaId) {
setCompanies([]);
if (companyId) setCompanyId('');
return;
}
listCompaniesForArea(areaId)
.then((rows) => {
setCompanies(rows);
if (companyId && !rows.some((item) => item.id === companyId)) setCompanyId('');
})
.catch(() => setCompanies([]));
}, [areaId, companyId, setCompanyId]);
const selectedArea = useMemo(() => areas.find((item) => item.id === areaId) ?? null, [areas, areaId]);
const selectedCompany = useMemo(() => companies.find((item) => item.id === companyId) ?? null, [companies, companyId]);
const submitSearch = (event: FormEvent) => {
event.preventDefault();
const term = search.trim();
if (!term || !canReadInventory) return;
navigate(`/inventarios?view=list&search=${encodeURIComponent(term)}`);
};
if (!canReadInventory && !canReadInspections) return null;
return <section className="operational-context-bar" aria-label="Contexto de trabajo">
<div className="operational-context-summary">
<span className="operational-context-icon"><Icon name="layers" size={17} /></span>
<div>
<small>CONTEXTO DE TRABAJO</small>
<strong>{selectedArea ? selectedArea.name : 'Todas las Áreas'}{selectedCompany ? ` · ${selectedCompany.name}` : ''}</strong>
</div>
</div>
<div className="operational-context-selectors">
<label>
<span>Área</span>
<SearchableSelect value={areaId} onChange={(event) => setAreaId(event.target.value)}>
<option value="">Todas las Áreas</option>
{areas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}
</SearchableSelect>
</label>
<label>
<span>Operadora</span>
<SearchableSelect value={companyId} disabled={!areaId} onChange={(event) => setCompanyId(event.target.value)}>
<option value="">{areaId ? 'Todas las Operadoras' : 'Primero elegí un Área'}</option>
{companies.map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}
</SearchableSelect>
</label>
</div>
{canReadInventory && <form className="operational-context-search" onSubmit={submitSearch}>
<Icon name="search" size={16} />
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Buscar en el Inventario…" />
<button type="submit">Buscar</button>
</form>}
<div className="operational-context-actions">
{canReadInventory && <Link className="button secondary compact" to="/inventarios">Inventario</Link>}
{canReadInspections && <Link className="button secondary compact" to="/inspecciones">Inspecciones</Link>}
{(areaId || companyId) && <button type="button" className="button text compact" onClick={clearContext}>Ver todo</button>}
</div>
</section>;
}
+228
View File
@@ -0,0 +1,228 @@
import {
Children,
Fragment,
isValidElement,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';
import type {
ReactElement,
ReactNode,
SelectHTMLAttributes,
} from 'react';
import { createPortal } from 'react-dom';
import { Icon } from './Icon';
interface FlatOption {
value: string;
label: string;
disabled: boolean;
}
type SearchableSelectProps = SelectHTMLAttributes<HTMLSelectElement> & {
searchPlaceholder?: string;
};
function textContent(node: ReactNode): string {
if (node == null || typeof node === 'boolean') return '';
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(textContent).join('');
if (isValidElement(node)) {
return textContent((node.props as { children?: ReactNode }).children);
}
return '';
}
function flattenOptions(node: ReactNode, target: FlatOption[]): void {
Children.forEach(node, (child) => {
if (!isValidElement(child)) return;
const element = child as ReactElement<{
children?: ReactNode;
value?: string | number;
disabled?: boolean;
}>;
if (element.type === 'option') {
target.push({
value: element.props.value == null ? textContent(element.props.children) : String(element.props.value),
label: textContent(element.props.children).trim(),
disabled: Boolean(element.props.disabled),
});
return;
}
if (element.type === Fragment || element.props.children != null) {
flattenOptions(element.props.children, target);
}
});
}
function normalized(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLocaleLowerCase('es-AR')
.trim();
}
export function SearchableSelect({
children,
className,
disabled,
value,
defaultValue,
onChange,
onBlur,
onFocus,
searchPlaceholder = 'Buscar…',
id,
...selectProps
}: SearchableSelectProps) {
const selectRef = useRef<HTMLSelectElement | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const searchRef = useRef<HTMLInputElement | null>(null);
const popupRef = useRef<HTMLDivElement | null>(null);
const generatedId = useId();
const resolvedId = id || generatedId;
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [popupStyle, setPopupStyle] = useState({ top: 0, left: 0, width: 240 });
const options = useMemo(() => {
const result: FlatOption[] = [];
flattenOptions(children, result);
return result;
}, [children]);
const selectedValue = value == null
? defaultValue == null ? '' : String(defaultValue)
: String(value);
const selected = options.find((option) => option.value === selectedValue);
const placeholder = options.find((option) => option.value === '')?.label || 'Seleccionar…';
const queryNormalized = normalized(query);
const filtered = queryNormalized
? options.filter((option) => normalized(option.label).includes(queryNormalized))
: options;
const positionPopup = () => {
const rect = buttonRef.current?.getBoundingClientRect();
if (!rect) return;
const margin = 8;
const desiredWidth = Math.max(rect.width, 260);
const width = Math.min(desiredWidth, window.innerWidth - margin * 2);
const left = Math.max(margin, Math.min(rect.left, window.innerWidth - width - margin));
const roomBelow = window.innerHeight - rect.bottom;
const top = roomBelow >= 320
? rect.bottom + 5
: Math.max(margin, rect.top - Math.min(315, rect.top - margin));
setPopupStyle({ top, left, width });
};
const close = () => {
setOpen(false);
setQuery('');
};
const choose = (nextValue: string) => {
const native = selectRef.current;
if (!native) return;
const descriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
descriptor?.set?.call(native, nextValue);
native.dispatchEvent(new Event('change', { bubbles: true }));
close();
window.setTimeout(() => buttonRef.current?.focus(), 0);
};
useEffect(() => {
if (!open) return;
positionPopup();
const reposition = () => positionPopup();
const outside = (event: MouseEvent) => {
const node = event.target as Node;
if (popupRef.current?.contains(node) || buttonRef.current?.contains(node)) return;
close();
};
window.addEventListener('resize', reposition);
window.addEventListener('scroll', reposition, true);
document.addEventListener('mousedown', outside);
window.setTimeout(() => searchRef.current?.focus(), 0);
return () => {
window.removeEventListener('resize', reposition);
window.removeEventListener('scroll', reposition, true);
document.removeEventListener('mousedown', outside);
};
}, [open]);
return <div className={`searchable-select${className ? ` ${className}` : ''}`}>
<select
{...selectProps}
ref={selectRef}
id={`${resolvedId}-native`}
className="searchable-select-native"
disabled={disabled}
value={value}
defaultValue={defaultValue}
onChange={onChange}
onBlur={onBlur}
onFocus={onFocus}
tabIndex={-1}
aria-hidden="true"
>{children}</select>
<button
ref={buttonRef}
id={resolvedId}
type="button"
className="searchable-select-trigger"
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => {
if (disabled) return;
positionPopup();
setOpen((current) => !current);
}}
>
<span className={!selectedValue ? 'placeholder' : ''}>{selected?.label || placeholder}</span>
<Icon name="chevron" size={14} />
</button>
{open && createPortal(<div
ref={popupRef}
className="searchable-select-popup"
style={{ top: popupStyle.top, left: popupStyle.left, width: popupStyle.width }}
>
<div className="searchable-select-search"><Icon name="search" size={14} /><input
ref={searchRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
close();
buttonRef.current?.focus();
}
if (event.key === 'Enter') {
const first = filtered.find((option) => !option.disabled);
if (first) {
event.preventDefault();
choose(first.value);
}
}
}}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
/></div>
<div className="searchable-select-options" role="listbox">
{filtered.length === 0 ? <div className="searchable-select-empty">Sin coincidencias</div> : filtered.map((option, index) => <button
type="button"
role="option"
aria-selected={option.value === selectedValue}
className={option.value === selectedValue ? 'selected' : ''}
disabled={option.disabled}
key={`${option.value}-${index}`}
onClick={() => choose(option.value)}
><span>{option.label || '—'}</span>{option.value === selectedValue && <Icon name="check" size={14} />}</button>)}
</div>
</div>, document.body)}
</div>;
}