chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface AutocompleteOption {
|
||||
id: string;
|
||||
label: string;
|
||||
sub?: string; // secondary label shown in smaller text
|
||||
}
|
||||
|
||||
interface AutocompleteInputProps {
|
||||
options: AutocompleteOption[];
|
||||
value: string; // selected id
|
||||
onChange: (id: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function normalize(s: string) {
|
||||
return s.toLowerCase().replace(/[-_\s]/g, "");
|
||||
}
|
||||
|
||||
function scoreMatch(option: AutocompleteOption, query: string): number {
|
||||
const q = normalize(query);
|
||||
const label = normalize(option.label);
|
||||
const sub = normalize(option.sub ?? "");
|
||||
if (label.startsWith(q) || sub.startsWith(q)) return 2;
|
||||
if (label.includes(q) || sub.includes(q)) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function AutocompleteInput({ options, value, onChange, placeholder = "Search…", className }: AutocompleteInputProps) {
|
||||
const selected = options.find((o) => o.id === value);
|
||||
const [input, setInput] = useState(selected?.label ?? "");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Keep input text in sync when selected value changes externally
|
||||
useEffect(() => {
|
||||
setInput(selected?.label ?? "");
|
||||
}, [selected?.label]);
|
||||
|
||||
const filtered = input.trim() === "" || (selected && input === selected.label)
|
||||
? options.slice(0, 20)
|
||||
: options
|
||||
.map((o) => ({ o, score: scoreMatch(o, input) }))
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(({ o }) => o)
|
||||
.slice(0, 20);
|
||||
|
||||
function select(opt: AutocompleteOption) {
|
||||
onChange(opt.id);
|
||||
setInput(opt.label);
|
||||
setOpen(false);
|
||||
setActiveIdx(0);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
onChange("");
|
||||
setInput("");
|
||||
setOpen(true);
|
||||
setActiveIdx(0);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); setActiveIdx((i) => Math.min(i + 1, filtered.length - 1)); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); setActiveIdx((i) => Math.max(i - 1, 0)); }
|
||||
else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const opt = filtered[activeIdx];
|
||||
if (opt) select(opt);
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
// Restore display text to match current selection
|
||||
setInput(selected?.label ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setInput(selected?.label ?? "");
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open, selected?.label]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative ${className ?? ""}`}>
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setOpen(true);
|
||||
setActiveIdx(0);
|
||||
// Clear selection if user edits away from selected label
|
||||
if (selected && e.target.value !== selected.label) onChange("");
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="w-full px-3 py-2 pr-7 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 leading-none text-base"
|
||||
aria-label="Clear"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && filtered.length > 0 && (
|
||||
<ul
|
||||
className="absolute z-50 left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden max-h-56 overflow-y-auto"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{filtered.map((opt, idx) => (
|
||||
<li key={opt.id}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); select(opt); }}
|
||||
className={`w-full text-left px-3 py-2 text-sm flex items-baseline gap-2 transition-colors ${
|
||||
idx === activeIdx
|
||||
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300"
|
||||
: "text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{opt.label}</span>
|
||||
{opt.sub && <span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">{opt.sub}</span>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
|
||||
interface BatchAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "danger";
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface BatchActionBarProps {
|
||||
count: number;
|
||||
actions: BatchAction[];
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function BatchActionBar({ count, actions, onClear }: BatchActionBarProps) {
|
||||
if (count === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-72 right-6 z-40 flex items-center gap-4 bg-gray-900 text-white px-5 py-3 rounded-xl shadow-2xl border border-gray-700">
|
||||
<span className="text-sm font-medium shrink-0">
|
||||
{count} selected
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 text-sm font-medium rounded-lg transition-colors disabled:opacity-50",
|
||||
action.variant === "danger"
|
||||
? "bg-red-600 hover:bg-red-700 text-white"
|
||||
: "bg-white/10 hover:bg-white/20 text-white",
|
||||
)}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="text-sm text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import type { ColumnDef } from "@planarchy/shared";
|
||||
|
||||
interface ColumnTogglePanelProps {
|
||||
allColumns: ColumnDef[];
|
||||
visibleKeys: string[];
|
||||
onSetVisible: (keys: string[]) => void;
|
||||
defaultKeys: string[];
|
||||
}
|
||||
|
||||
export function ColumnTogglePanel({
|
||||
allColumns,
|
||||
visibleKeys,
|
||||
onSetVisible,
|
||||
defaultKeys,
|
||||
}: ColumnTogglePanelProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
const dragKey = useRef<string | null>(null);
|
||||
|
||||
function toggle(key: string) {
|
||||
const col = allColumns.find((c) => c.key === key);
|
||||
if (!col?.hideable) return; // always-visible columns can't be toggled
|
||||
const next = visibleKeys.includes(key)
|
||||
? visibleKeys.filter((k) => k !== key)
|
||||
: [...visibleKeys, key];
|
||||
onSetVisible(next);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
onSetVisible(defaultKeys);
|
||||
}
|
||||
|
||||
const reorder = useCallback((fromKey: string, toKey: string) => {
|
||||
if (fromKey === toKey) return;
|
||||
const next = [...visibleKeys];
|
||||
const from = next.indexOf(fromKey);
|
||||
const to = next.indexOf(toKey);
|
||||
if (from === -1 || to === -1) return;
|
||||
next.splice(from, 1);
|
||||
next.splice(to, 0, fromKey);
|
||||
onSetVisible(next);
|
||||
}, [visibleKeys, onSetVisible]);
|
||||
|
||||
const builtins = allColumns.filter((c) => !c.isCustom);
|
||||
const customs = allColumns.filter((c) => c.isCustom);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={panelRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
title="Toggle columns"
|
||||
className={`p-1.5 rounded-lg border text-sm transition-colors ${
|
||||
open
|
||||
? "border-brand-400 bg-brand-50 text-brand-700"
|
||||
: "border-gray-300 text-gray-500 hover:border-gray-400 hover:text-gray-700"
|
||||
}`}
|
||||
aria-label="Column visibility"
|
||||
>
|
||||
{/* columns icon */}
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<rect x="1" y="3" width="4" height="10" rx="1" stroke="currentColor" strokeWidth="1.5"/>
|
||||
<rect x="6" y="3" width="4" height="10" rx="1" stroke="currentColor" strokeWidth="1.5"/>
|
||||
<rect x="11" y="3" width="4" height="10" rx="1" stroke="currentColor" strokeWidth="1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-52 bg-white border border-gray-200 rounded-xl shadow-xl py-2">
|
||||
<div className="px-3 pb-1 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Columns</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="text-xs text-brand-600 hover:text-brand-800"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{builtins.map((col) => {
|
||||
const isVisible = visibleKeys.includes(col.key);
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
draggable={col.hideable && isVisible}
|
||||
onDragStart={() => { dragKey.current = col.key; }}
|
||||
onDragOver={(e) => { e.preventDefault(); }}
|
||||
onDrop={() => { if (dragKey.current) reorder(dragKey.current, col.key); dragKey.current = null; }}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 ${
|
||||
!col.hideable ? "opacity-50" : "cursor-grab"
|
||||
}`}
|
||||
>
|
||||
{col.hideable && isVisible && (
|
||||
<span className="text-gray-300 text-xs select-none">⠿</span>
|
||||
)}
|
||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isVisible}
|
||||
onChange={() => toggle(col.key)}
|
||||
disabled={!col.hideable}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{col.label}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{customs.length > 0 && (
|
||||
<>
|
||||
<div className="my-1 border-t border-gray-100" />
|
||||
<p className="px-3 py-1 text-xs text-gray-400 font-medium">Custom Fields</p>
|
||||
{customs.map((col) => {
|
||||
const isVisible = visibleKeys.includes(col.key);
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
draggable={isVisible}
|
||||
onDragStart={() => { dragKey.current = col.key; }}
|
||||
onDragOver={(e) => { e.preventDefault(); }}
|
||||
onDrop={() => { if (dragKey.current) reorder(dragKey.current, col.key); dragKey.current = null; }}
|
||||
className="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 cursor-grab"
|
||||
>
|
||||
{isVisible && <span className="text-gray-300 text-xs select-none">⠿</span>}
|
||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isVisible}
|
||||
onChange={() => toggle(col.key)}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{col.label}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: "default" | "danger";
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
variant = "default",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onCancel();
|
||||
}}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md">
|
||||
<div className="px-6 py-5">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-sm text-gray-600">{message}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={
|
||||
variant === "danger"
|
||||
? "px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
|
||||
: "px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors"
|
||||
}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { FieldType } from "@planarchy/shared";
|
||||
import type { BlueprintFieldDefinition } from "@planarchy/shared";
|
||||
import type { CustomFieldFilter } from "~/hooks/useFilters.js";
|
||||
|
||||
interface Props {
|
||||
/** Filterable field definitions from global blueprints */
|
||||
filterableFields: (BlueprintFieldDefinition & { blueprintName: string })[];
|
||||
activeFilters: CustomFieldFilter[];
|
||||
onSetFilter: (key: string, value: string, type: FieldType) => void;
|
||||
}
|
||||
|
||||
const INPUT_CLS =
|
||||
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white";
|
||||
|
||||
export function CustomFieldFilterBar({ filterableFields, activeFilters, onSetFilter }: Props) {
|
||||
if (filterableFields.length === 0) return null;
|
||||
|
||||
function getValue(key: string) {
|
||||
return activeFilters.find((f) => f.key === key)?.value ?? "";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">Custom:</span>
|
||||
{filterableFields.map((field) => {
|
||||
const value = getValue(field.key);
|
||||
|
||||
if (field.type === FieldType.BOOLEAN) {
|
||||
return (
|
||||
<select
|
||||
key={field.key}
|
||||
value={value}
|
||||
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
|
||||
className={INPUT_CLS}
|
||||
aria-label={field.label}
|
||||
>
|
||||
<option value="">{field.label}: any</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === FieldType.SELECT && field.options && field.options.length > 0) {
|
||||
return (
|
||||
<select
|
||||
key={field.key}
|
||||
value={value}
|
||||
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
|
||||
className={INPUT_CLS}
|
||||
aria-label={field.label}
|
||||
>
|
||||
<option value="">{field.label}: any</option>
|
||||
{field.options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label || opt.value}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === FieldType.NUMBER) {
|
||||
return (
|
||||
<input
|
||||
key={field.key}
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
|
||||
placeholder={field.label}
|
||||
className={`${INPUT_CLS} w-32`}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === FieldType.DATE) {
|
||||
return (
|
||||
<input
|
||||
key={field.key}
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
|
||||
className={INPUT_CLS}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// TEXT, TEXTAREA, URL, EMAIL, MULTI_SELECT — text search
|
||||
return (
|
||||
<input
|
||||
key={field.key}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onSetFilter(field.key, e.target.value, field.type)}
|
||||
placeholder={field.label}
|
||||
className={`${INPUT_CLS} w-40`}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DateInput — always displays dd/mm/yyyy regardless of browser/OS locale.
|
||||
*
|
||||
* - Text field shows and accepts dd/mm/yyyy (auto-inserts slashes while typing)
|
||||
* - Calendar icon opens a hidden <input type="date"> for native picker
|
||||
* - Internal value / onChange contract: yyyy-mm-dd strings (same as <input type="date">)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
// ── Conversion helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function isoToDisplay(iso: string): string {
|
||||
if (!iso) return "";
|
||||
const [y, m, d] = iso.split("-");
|
||||
if (!y || !m || !d) return "";
|
||||
return `${d}/${m}/${y}`;
|
||||
}
|
||||
|
||||
function displayToISO(display: string): string {
|
||||
const digits = display.replace(/\D/g, "");
|
||||
if (digits.length !== 8) return "";
|
||||
const d = digits.slice(0, 2);
|
||||
const m = digits.slice(2, 4);
|
||||
const y = digits.slice(4, 8);
|
||||
if (parseInt(d) < 1 || parseInt(d) > 31) return "";
|
||||
if (parseInt(m) < 1 || parseInt(m) > 12) return "";
|
||||
if (parseInt(y) < 1900 || parseInt(y) > 2100) return "";
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
// Auto-insert slashes as the user types
|
||||
function autoSlash(raw: string): string {
|
||||
const digits = raw.replace(/\D/g, "").slice(0, 8);
|
||||
if (digits.length <= 2) return digits;
|
||||
if (digits.length <= 4) return `${digits.slice(0, 2)}/${digits.slice(2)}`;
|
||||
return `${digits.slice(0, 2)}/${digits.slice(2, 4)}/${digits.slice(4)}`;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DateInputProps {
|
||||
value: string; // yyyy-mm-dd (empty string = unset)
|
||||
onChange: (v: string) => void; // called with yyyy-mm-dd when valid
|
||||
id?: string;
|
||||
className?: string;
|
||||
required?: boolean;
|
||||
min?: string; // yyyy-mm-dd
|
||||
max?: string; // yyyy-mm-dd
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DateInput({
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
className,
|
||||
required,
|
||||
min,
|
||||
max,
|
||||
disabled,
|
||||
}: DateInputProps) {
|
||||
const [display, setDisplay] = useState(isoToDisplay(value));
|
||||
const hiddenRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Sync display when value is changed externally
|
||||
useEffect(() => {
|
||||
setDisplay(isoToDisplay(value));
|
||||
}, [value]);
|
||||
|
||||
function handleTextChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const formatted = autoSlash(e.target.value);
|
||||
setDisplay(formatted);
|
||||
const iso = displayToISO(formatted);
|
||||
if (iso) onChange(iso);
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
// Re-normalise display to canonical format on blur
|
||||
const iso = displayToISO(display);
|
||||
if (iso) {
|
||||
setDisplay(isoToDisplay(iso));
|
||||
} else if (display && display.replace(/\D/g, "").length < 8) {
|
||||
// Incomplete — keep as-is so user can fix it
|
||||
} else if (!display) {
|
||||
// Cleared
|
||||
setDisplay("");
|
||||
}
|
||||
}
|
||||
|
||||
function handleHiddenChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const iso = e.target.value; // yyyy-mm-dd from native picker
|
||||
if (iso) {
|
||||
setDisplay(isoToDisplay(iso));
|
||||
onChange(iso);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="dd/mm/yyyy"
|
||||
value={display}
|
||||
onChange={handleTextChange}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
className={clsx("pr-8", className)}
|
||||
/>
|
||||
{/* Hidden native date input driven by the calendar icon */}
|
||||
<input
|
||||
ref={hiddenRef}
|
||||
type="date"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={handleHiddenChange}
|
||||
className="sr-only absolute inset-0 w-full opacity-0 pointer-events-none"
|
||||
/>
|
||||
{/* Calendar icon — clicking opens the hidden picker */}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
disabled={disabled}
|
||||
aria-label="Open date picker"
|
||||
onClick={() => hiddenRef.current?.showPicker()}
|
||||
className="absolute right-2 text-gray-400 hover:text-gray-600 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
interface DraggableTableRowProps {
|
||||
id: string;
|
||||
/** Shared ref across all rows in the table — holds the ID currently being dragged. */
|
||||
dragRef: React.MutableRefObject<string | null>;
|
||||
/**
|
||||
* Called when another row is dropped onto this row.
|
||||
* Receives the ID of the row that was dragged (not this row's ID).
|
||||
*/
|
||||
onDrop: (draggedId: string) => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table row with a left-side drag handle for manual row reordering.
|
||||
*
|
||||
* Usage: replace <tr> with <DraggableTableRow>, add <th className="w-8 px-2" /> as the
|
||||
* first header cell. Each table shares one dragRef (useRef<string | null>(null)).
|
||||
*/
|
||||
export function DraggableTableRow({
|
||||
id,
|
||||
dragRef,
|
||||
onDrop,
|
||||
children,
|
||||
className = "",
|
||||
}: DraggableTableRowProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={`${className} ${isDragOver ? "border-t-2 border-brand-400" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (dragRef.current && dragRef.current !== id) setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
if (dragRef.current && dragRef.current !== id) {
|
||||
// Pass the dragged ID so the caller knows what moved where
|
||||
onDrop(dragRef.current);
|
||||
}
|
||||
dragRef.current = null;
|
||||
}}
|
||||
>
|
||||
{/* Drag handle — left-most cell */}
|
||||
<td
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
dragRef.current = id;
|
||||
// Semi-transparent ghost
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
dragRef.current = null;
|
||||
setIsDragOver(false);
|
||||
}}
|
||||
className="w-8 px-2 py-3 cursor-grab active:cursor-grabbing select-none text-gray-300 hover:text-gray-400 text-center"
|
||||
title="Drag to reorder"
|
||||
>
|
||||
⠿
|
||||
</td>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
interface FilterBarProps {
|
||||
children: React.ReactNode;
|
||||
hasActiveFilters?: boolean;
|
||||
onClearFilters?: () => void;
|
||||
}
|
||||
|
||||
export function FilterBar({ children, hasActiveFilters, onClearFilters }: FilterBarProps) {
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
{children}
|
||||
{hasActiveFilters && onClearFilters && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearFilters}
|
||||
className="px-3 py-2 text-sm text-gray-500 hover:text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface Chip {
|
||||
label: string;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
interface FilterChipsProps {
|
||||
chips: Chip[];
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
export function FilterChips({ chips, onClearAll }: FilterChipsProps) {
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{chips.map((chip) => (
|
||||
<span
|
||||
key={chip.label}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-brand-50 text-brand-700 border border-brand-200 px-2.5 py-0.5 text-xs"
|
||||
>
|
||||
{chip.label}
|
||||
<button
|
||||
type="button"
|
||||
onClick={chip.onRemove}
|
||||
className="ml-0.5 hover:text-brand-900 transition-colors"
|
||||
aria-label={`Remove filter: ${chip.label}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearAll}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
|
||||
interface InfiniteScrollSentinelProps {
|
||||
onVisible: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function InfiniteScrollSentinel({ onVisible, isLoading }: InfiniteScrollSentinelProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const obs = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting && !isLoading) onVisible();
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, [isLoading, onVisible]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="h-6 flex items-center justify-center">
|
||||
{isLoading && (
|
||||
<div className="w-4 h-4 border-2 border-brand-500 border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface InfoTooltipProps {
|
||||
content: React.ReactNode;
|
||||
/** Position relative to the trigger icon. Default: "top" */
|
||||
position?: "top" | "bottom";
|
||||
/** Extra width class, e.g. "w-72". Default: "w-60" */
|
||||
width?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small ℹ icon that shows a tooltip on hover / focus.
|
||||
* Rendered via a portal into document.body so it's never clipped by
|
||||
* ancestor overflow:hidden containers (table cells, widget cards, etc.).
|
||||
*/
|
||||
export function InfoTooltip({ content, position = "top", width = "w-60" }: InfoTooltipProps) {
|
||||
const [show, setShow] = useState(false);
|
||||
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
function computeCoords() {
|
||||
if (!btnRef.current) return;
|
||||
const rect = btnRef.current.getBoundingClientRect();
|
||||
if (position === "top") {
|
||||
setCoords({
|
||||
top: rect.top + window.scrollY - 8, // 8px gap + arrow
|
||||
left: rect.left + window.scrollX + rect.width / 2,
|
||||
});
|
||||
} else {
|
||||
setCoords({
|
||||
top: rect.bottom + window.scrollY + 8,
|
||||
left: rect.left + window.scrollX + rect.width / 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleShow() {
|
||||
computeCoords();
|
||||
setShow(true);
|
||||
}
|
||||
|
||||
// Recompute on scroll/resize while shown so tooltip follows the trigger
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
const update = () => computeCoords();
|
||||
window.addEventListener("scroll", update, true);
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", update, true);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [show]);
|
||||
|
||||
const tooltipStyle: React.CSSProperties =
|
||||
position === "top"
|
||||
? { position: "fixed", top: coords.top, left: coords.left, transform: "translate(-50%, -100%)" }
|
||||
: { position: "fixed", top: coords.top, left: coords.left, transform: "translateX(-50%)" };
|
||||
|
||||
const arrowClass =
|
||||
position === "top"
|
||||
? "top-full border-t-gray-900 border-l-transparent border-r-transparent border-b-transparent border-l-4 border-r-4 border-t-4 border-b-0"
|
||||
: "bottom-full border-b-gray-900 border-l-transparent border-r-transparent border-t-transparent border-l-4 border-r-4 border-b-4 border-t-0";
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex items-center">
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
onMouseEnter={handleShow}
|
||||
onMouseLeave={() => setShow(false)}
|
||||
onFocus={handleShow}
|
||||
onBlur={() => setShow(false)}
|
||||
className="ml-1 w-3.5 h-3.5 rounded-full bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-300 text-[9px] font-bold flex items-center justify-center hover:bg-gray-300 dark:hover:bg-gray-500 cursor-help flex-shrink-0 leading-none"
|
||||
aria-label="More information"
|
||||
>
|
||||
i
|
||||
</button>
|
||||
|
||||
{show &&
|
||||
createPortal(
|
||||
<div
|
||||
style={tooltipStyle}
|
||||
className={`z-[9999] ${width} bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-xl pointer-events-none`}
|
||||
>
|
||||
{content}
|
||||
<span className={`absolute left-1/2 -translate-x-1/2 w-0 h-0 ${arrowClass}`} />
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Thin brand-colored progress bar at the top of the page.
|
||||
* Animates to 100% on route change, then fades out.
|
||||
* Pure CSS animation — no external dependency.
|
||||
*/
|
||||
export function NavProgressBar() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [width, setWidth] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const prevPathRef = useRef<string | null>(null);
|
||||
|
||||
// Detect link clicks to start the bar early
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
const target = (e.target as Element).closest("a");
|
||||
if (!target) return;
|
||||
const href = target.getAttribute("href");
|
||||
if (!href || href.startsWith("http") || href.startsWith("#") || href.startsWith("mailto")) return;
|
||||
// Internal navigation — start bar
|
||||
setVisible(true);
|
||||
setWidth(60); // jump to 60% immediately, await route change for completion
|
||||
}
|
||||
document.addEventListener("click", handleClick);
|
||||
return () => document.removeEventListener("click", handleClick);
|
||||
}, []);
|
||||
|
||||
// Complete bar when route actually changes
|
||||
useEffect(() => {
|
||||
const current = pathname + searchParams.toString();
|
||||
if (prevPathRef.current !== null && prevPathRef.current !== current) {
|
||||
// Route changed — complete the bar
|
||||
setWidth(100);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
setVisible(false);
|
||||
setWidth(0);
|
||||
}, 350);
|
||||
}
|
||||
prevPathRef.current = current;
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
|
||||
|
||||
if (!visible && width === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="fixed top-0 left-0 right-0 z-[9999] h-0.5 pointer-events-none"
|
||||
>
|
||||
<div
|
||||
className="h-full bg-brand-500 transition-all ease-out"
|
||||
style={{
|
||||
width: `${width}%`,
|
||||
transitionDuration: width === 100 ? "200ms" : "400ms",
|
||||
opacity: visible ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||
import type { ProjectStatus } from "@planarchy/shared";
|
||||
|
||||
interface ProjectComboboxProps {
|
||||
value: string | null;
|
||||
onChange: (id: string | null) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
status?: ProjectStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProjectCombobox({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Search project…",
|
||||
disabled = false,
|
||||
status,
|
||||
className = "",
|
||||
}: ProjectComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data } = trpc.project.list.useQuery(
|
||||
{ search: debouncedSearch || undefined, limit: 15, ...(status ? { status } : {}) },
|
||||
{ enabled: open, staleTime: 30_000 },
|
||||
);
|
||||
|
||||
const projects = data?.projects ?? [];
|
||||
|
||||
const { data: allData } = trpc.project.list.useQuery(
|
||||
{ limit: 500 },
|
||||
{ enabled: !!value && !open, staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const selectedLabel = useMemo(() => {
|
||||
if (!value) return "";
|
||||
const fromOpen = projects.find((p) => p.id === value);
|
||||
if (fromOpen) return `${fromOpen.shortCode} — ${fromOpen.name}`;
|
||||
const fromAll = allData?.projects.find((p) => p.id === value);
|
||||
if (fromAll) return `${fromAll.shortCode} — ${fromAll.name}`;
|
||||
return value;
|
||||
}, [value, projects, allData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
function handleOpen() {
|
||||
if (disabled) return;
|
||||
setOpen(true);
|
||||
setSearch("");
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
|
||||
function select(id: string | null) {
|
||||
onChange(id);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={containerRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
disabled={disabled}
|
||||
className={`w-full text-left px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
open ? "border-brand-500 ring-2 ring-brand-500" : "hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<span className={selectedLabel ? "text-gray-900" : "text-gray-400"}>
|
||||
{selectedLabel || placeholder}
|
||||
</span>
|
||||
{value && !disabled && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onMouseDown={(e) => { e.stopPropagation(); select(null); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") select(null); }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 text-lg leading-none"
|
||||
aria-label="Clear"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-50 bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden">
|
||||
<div className="p-2 border-b border-gray-100">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Type to search…"
|
||||
className="w-full px-2 py-1 text-sm border-0 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<ul className="max-h-52 overflow-y-auto py-1">
|
||||
{projects.length === 0 ? (
|
||||
<li className="px-3 py-2 text-sm text-gray-400">No results</li>
|
||||
) : (
|
||||
projects.map((p) => (
|
||||
<li key={p.id}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={() => select(p.id)}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-brand-50 ${
|
||||
p.id === value ? "bg-brand-50 text-brand-700 font-medium" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-xs text-gray-400 mr-1.5">{p.shortCode}</span>
|
||||
<span>{p.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||
|
||||
interface ResourceComboboxProps {
|
||||
value: string | null;
|
||||
onChange: (id: string | null) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
isActive?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ResourceCombobox({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Search resource…",
|
||||
disabled = false,
|
||||
isActive = true,
|
||||
className = "",
|
||||
}: ResourceComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data } = trpc.resource.list.useQuery(
|
||||
{ search: debouncedSearch || undefined, limit: 15, isActive },
|
||||
{ enabled: open, staleTime: 30_000 },
|
||||
);
|
||||
|
||||
const resources = data?.resources ?? [];
|
||||
|
||||
// Resolve display name for currently selected value
|
||||
const { data: selectedData } = trpc.resource.list.useQuery(
|
||||
{ search: undefined, limit: 500, isActive: undefined as unknown as boolean },
|
||||
{ enabled: !!value && !open, staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const selectedLabel = useMemo(() => {
|
||||
if (!value) return "";
|
||||
const fromOpen = resources.find((r) => r.id === value);
|
||||
if (fromOpen) return `${fromOpen.displayName} (${fromOpen.eid})`;
|
||||
const fromSelected = selectedData?.resources.find((r) => r.id === value);
|
||||
if (fromSelected) return `${fromSelected.displayName} (${fromSelected.eid})`;
|
||||
return value;
|
||||
}, [value, resources, selectedData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
function handleOpen() {
|
||||
if (disabled) return;
|
||||
setOpen(true);
|
||||
setSearch("");
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
|
||||
function select(id: string | null) {
|
||||
onChange(id);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={containerRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
disabled={disabled}
|
||||
className={`w-full text-left px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
open ? "border-brand-500 ring-2 ring-brand-500" : "hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<span className={selectedLabel ? "text-gray-900" : "text-gray-400"}>
|
||||
{selectedLabel || placeholder}
|
||||
</span>
|
||||
{value && !disabled && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onMouseDown={(e) => { e.stopPropagation(); select(null); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") select(null); }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 text-lg leading-none"
|
||||
aria-label="Clear"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-50 bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden">
|
||||
<div className="p-2 border-b border-gray-100">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Type to search…"
|
||||
className="w-full px-2 py-1 text-sm border-0 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<ul className="max-h-52 overflow-y-auto py-1">
|
||||
{resources.length === 0 ? (
|
||||
<li className="px-3 py-2 text-sm text-gray-400">No results</li>
|
||||
) : (
|
||||
resources.map((r) => (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={() => select(r.id)}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-brand-50 ${
|
||||
r.id === value ? "bg-brand-50 text-brand-700 font-medium" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span>{r.displayName}</span>
|
||||
<span className="ml-1.5 text-xs text-gray-400">{r.eid}</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface SkillTagInputProps {
|
||||
value: string[];
|
||||
onChange: (skills: string[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SkillTagInput({ value, onChange, placeholder = "Add skill…", className }: SkillTagInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIdx, setActiveIdx] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: analytics } = trpc.resource.getSkillsAnalytics.useQuery(undefined, {
|
||||
staleTime: 120_000,
|
||||
});
|
||||
|
||||
const aggregated: { skill: string }[] = analytics?.aggregated ?? [];
|
||||
const suggestions: string[] = aggregated
|
||||
.map((a) => a.skill)
|
||||
.filter((s) => !value.includes(s) && s.toLowerCase().includes(input.toLowerCase()))
|
||||
.slice(0, 8);
|
||||
|
||||
function addSkill(skill: string) {
|
||||
const trimmed = skill.trim();
|
||||
if (trimmed && !value.includes(trimmed)) {
|
||||
onChange([...value, trimmed]);
|
||||
}
|
||||
setInput("");
|
||||
setOpen(false);
|
||||
setActiveIdx(-1);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function removeSkill(skill: string) {
|
||||
onChange(value.filter((s) => s !== skill));
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (activeIdx >= 0 && suggestions[activeIdx]) {
|
||||
addSkill(suggestions[activeIdx]);
|
||||
} else if (input.trim()) {
|
||||
addSkill(input);
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIdx((i) => Math.min(i + 1, suggestions.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIdx((i) => Math.max(i - 1, -1));
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
setActiveIdx(-1);
|
||||
} else if (e.key === "Backspace" && input === "" && value.length > 0) {
|
||||
onChange(value.slice(0, -1));
|
||||
}
|
||||
}
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative ${className ?? ""}`}>
|
||||
{/* Tags + input row */}
|
||||
<div
|
||||
className="min-h-[42px] flex flex-wrap gap-1.5 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 cursor-text focus-within:ring-2 focus-within:ring-brand-500 focus-within:border-transparent"
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{value.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 bg-brand-100 dark:bg-brand-900/40 text-brand-800 dark:text-brand-300 text-xs font-medium rounded-full"
|
||||
>
|
||||
{skill}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSkill(skill)}
|
||||
className="hover:text-brand-600 dark:hover:text-brand-200 leading-none"
|
||||
aria-label={`Remove ${skill}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); setOpen(true); setActiveIdx(-1); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={value.length === 0 ? placeholder : ""}
|
||||
className="flex-1 min-w-[120px] outline-none bg-transparent text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && suggestions.length > 0 && (
|
||||
<ul
|
||||
className="absolute z-50 left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden max-h-48 overflow-y-auto"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{suggestions.map((skill, idx) => (
|
||||
<li key={skill}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); addSkill(skill); }}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
|
||||
idx === activeIdx
|
||||
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300"
|
||||
: "text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
{skill}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { InfoTooltip } from "./InfoTooltip.js";
|
||||
|
||||
interface SortIconProps {
|
||||
dir: "asc" | "desc" | null;
|
||||
}
|
||||
|
||||
function SortIcon({ dir }: SortIconProps) {
|
||||
return (
|
||||
<span className="inline-flex flex-col leading-none ml-0.5">
|
||||
<svg width="8" height="12" viewBox="0 0 8 12" fill="none" aria-hidden>
|
||||
{/* Up chevron */}
|
||||
<path
|
||||
d="M1 5L4 2L7 5"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={dir === "asc" ? "stroke-brand-600" : "stroke-gray-300"}
|
||||
/>
|
||||
{/* Down chevron */}
|
||||
<path
|
||||
d="M1 7L4 10L7 7"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={dir === "desc" ? "stroke-brand-600" : "stroke-gray-300"}
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableColumnHeaderProps {
|
||||
label: string;
|
||||
field: string;
|
||||
sortField: string | null;
|
||||
sortDir: "asc" | "desc" | null;
|
||||
onSort: (field: string) => void;
|
||||
className?: string;
|
||||
align?: "left" | "right" | "center";
|
||||
tooltip?: string;
|
||||
tooltipWidth?: string;
|
||||
}
|
||||
|
||||
export function SortableColumnHeader({
|
||||
label,
|
||||
field,
|
||||
sortField,
|
||||
sortDir,
|
||||
onSort,
|
||||
className = "",
|
||||
align = "left",
|
||||
tooltip,
|
||||
tooltipWidth,
|
||||
}: SortableColumnHeaderProps) {
|
||||
const activeDir = sortField === field ? sortDir : null;
|
||||
const alignClass = align === "right" ? "justify-end" : align === "center" ? "justify-center" : "justify-start";
|
||||
|
||||
return (
|
||||
<th className={`px-3 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider ${className}`}>
|
||||
<div className={`flex items-center gap-0.5 ${alignClass}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(field)}
|
||||
className="flex items-center gap-0.5 hover:text-gray-700 transition-colors group"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<SortIcon dir={activeDir} />
|
||||
</button>
|
||||
{tooltip && <InfoTooltip content={tooltip} {...(tooltipWidth !== undefined ? { width: tooltipWidth } : {})} />}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user