diff --git a/src/components/BlueprintsQueue.tsx b/src/components/BlueprintsQueue.tsx new file mode 100644 index 0000000..681f43b --- /dev/null +++ b/src/components/BlueprintsQueue.tsx @@ -0,0 +1,437 @@ +/** + * ============================================================ + * STOPGAP: Upcoming Blueprints Queue + * ============================================================ + * This is a temporary feature for foremen to assign work to workers. + * Remove this file and its usage in Index.tsx when a better system is implemented. + * + * ClickHouse Table DDL (run this to create the table): + * + * CREATE TABLE IF NOT EXISTS default.blueprint_queue ( + * id UUID DEFAULT generateUUIDv4(), + * type_id UInt32, + * quantity Nullable(UInt32), + * added_by String, + * added_at DateTime DEFAULT now(), + * completed UInt8 DEFAULT 0 + * ) ENGINE = MergeTree() + * ORDER BY (added_at, id); + * + * To remove this feature: + * 1. Delete this file + * 2. Remove the tab and import from Index.tsx + * 3. Drop the table: DROP TABLE IF EXISTS default.blueprint_queue; + * ============================================================ + */ + +import { useState, useCallback, useEffect, useRef } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Trash2, Plus, RefreshCw, Check, Search } from 'lucide-react'; +import { getTypeIconUrl, fetchTypeName, TypeInfo } from '@/lib/api'; + +const CLICKHOUSE_URL = "https://mclickhouse.site.quack-lab.dev"; +const CLICKHOUSE_USER = "indy_jobs_ro_user"; +const CLICKHOUSE_PASSWORD = "Q9Cd5Z3j72NypTdNwKV7E7H83mv35mRc"; + +const TYPESENSE_URL = "https://eve-typesense.site.quack-lab.dev"; +const TYPESENSE_KEY = "#L46&&8UeJGE675zRR3kqzd6K!k6an7w"; + +// For write operations, you may need a different user with write permissions +const CLICKHOUSE_WRITE_USER = "indy_jobs_rw_user"; +const CLICKHOUSE_WRITE_PASSWORD = "WritePassword123"; // Update with actual write credentials + +export interface BlueprintQueueItem { + id: string; + type_id: number; + quantity: number | null; + added_by: string; + added_at: string; + completed: boolean; +} + +async function fetchBlueprintQueue(): Promise { + const query = ` + SELECT id, type_id, quantity, added_by, added_at, completed + FROM default.blueprint_queue + WHERE completed = 0 + ORDER BY added_at ASC + FORMAT JSON + `; + + const response = await fetch(`${CLICKHOUSE_URL}/?query=${encodeURIComponent(query)}`, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Authorization': 'Basic ' + btoa(CLICKHOUSE_USER + ":" + CLICKHOUSE_PASSWORD), + }, + }); + + if (!response.ok) { + throw new Error(`ClickHouse query failed: ${response.statusText}`); + } + + const result = await response.json(); + return (result.data || []).map((row: any) => ({ + ...row, + completed: row.completed === 1, + })); +} + +async function addToQueue(typeId: number, quantity: number | null, addedBy: string): Promise { + const quantityValue = quantity !== null ? quantity : 'NULL'; + const query = ` + INSERT INTO default.blueprint_queue (type_id, quantity, added_by) + VALUES (${typeId}, ${quantityValue}, '${addedBy.replace(/'/g, "''")}') + `; + + const response = await fetch(`${CLICKHOUSE_URL}/?query=${encodeURIComponent(query)}`, { + method: 'POST', + headers: { + 'Authorization': 'Basic ' + btoa(CLICKHOUSE_WRITE_USER + ":" + CLICKHOUSE_WRITE_PASSWORD), + }, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to add to queue: ${text}`); + } +} + +async function markCompleted(id: string): Promise { + const query = ` + ALTER TABLE default.blueprint_queue + UPDATE completed = 1 + WHERE id = '${id}' + `; + + const response = await fetch(`${CLICKHOUSE_URL}/?query=${encodeURIComponent(query)}`, { + method: 'POST', + headers: { + 'Authorization': 'Basic ' + btoa(CLICKHOUSE_WRITE_USER + ":" + CLICKHOUSE_WRITE_PASSWORD), + }, + }); + + if (!response.ok) { + throw new Error(`Failed to mark completed: ${response.statusText}`); + } +} + +async function removeFromQueue(id: string): Promise { + const query = ` + ALTER TABLE default.blueprint_queue + DELETE WHERE id = '${id}' + `; + + const response = await fetch(`${CLICKHOUSE_URL}/?query=${encodeURIComponent(query)}`, { + method: 'POST', + headers: { + 'Authorization': 'Basic ' + btoa(CLICKHOUSE_WRITE_USER + ":" + CLICKHOUSE_WRITE_PASSWORD), + }, + }); + + if (!response.ok) { + throw new Error(`Failed to remove from queue: ${response.statusText}`); + } +} + +// Hook to fetch type names for queue items +function useTypeNames(typeIds: number[]) { + const [typeNames, setTypeNames] = useState>({}); + + useEffect(() => { + const missingIds = typeIds.filter(id => !typeNames[id]); + if (missingIds.length === 0) return; + + Promise.all(missingIds.map(id => fetchTypeName(id).then(info => ({ id, info })))) + .then(results => { + const newNames: Record = {}; + results.forEach(({ id, info }) => { + if (info) newNames[id] = info; + }); + setTypeNames(prev => ({ ...prev, ...newNames })); + }); + }, [typeIds]); + + return typeNames; +} + +// Typesense search for types +interface TypesenseHit { + typeID: number; + typeName: string; +} + +async function searchTypes(query: string): Promise { + if (!query || query.length < 2) return []; + + const response = await fetch( + `${TYPESENSE_URL}/collections/invTypes/documents/search?q=${encodeURIComponent(query)}&query_by=typeName&per_page=10`, + { + headers: { + 'Accept': 'application/json', + 'X-TYPESENSE-API-KEY': TYPESENSE_KEY, + }, + } + ); + + if (!response.ok) return []; + + const result = await response.json(); + return (result.hits || []).map((hit: { document: TypesenseHit }) => hit.document); +} + +export function BlueprintsQueue() { + const queryClient = useQueryClient(); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [selectedType, setSelectedType] = useState(null); + const [showDropdown, setShowDropdown] = useState(false); + const [newQuantity, setNewQuantity] = useState(''); + const [addedBy, setAddedBy] = useState(() => localStorage.getItem('blueprint-queue-user') || ''); + const dropdownRef = useRef(null); + const searchInputRef = useRef(null); + + const { data: queue = [], isLoading, error, refetch, isFetching } = useQuery({ + queryKey: ['blueprint-queue'], + queryFn: fetchBlueprintQueue, + refetchInterval: 30000, + }); + + const typeNames = useTypeNames(queue.map(item => item.type_id)); + + // Debounced search + useEffect(() => { + if (!searchQuery || searchQuery.length < 2) { + setSearchResults([]); + return; + } + + const timer = setTimeout(async () => { + const results = await searchTypes(searchQuery); + setSearchResults(results); + setShowDropdown(results.length > 0); + }, 200); + + return () => clearTimeout(timer); + }, [searchQuery]); + + // Close dropdown on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setShowDropdown(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const addMutation = useMutation({ + mutationFn: ({ typeId, quantity, by }: { typeId: number; quantity: number | null; by: string }) => + addToQueue(typeId, quantity, by), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['blueprint-queue'] }); + setSearchQuery(''); + setSelectedType(null); + setNewQuantity(''); + }, + }); + + const completeMutation = useMutation({ + mutationFn: markCompleted, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blueprint-queue'] }), + }); + + const removeMutation = useMutation({ + mutationFn: removeFromQueue, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blueprint-queue'] }), + }); + + const handleSelectType = (hit: TypesenseHit) => { + setSelectedType(hit); + setSearchQuery(hit.typeName); + setShowDropdown(false); + }; + + const handleAdd = useCallback(() => { + if (!selectedType) return; + + const quantity = newQuantity ? parseInt(newQuantity, 10) : null; + const by = addedBy.trim() || 'Unknown'; + + localStorage.setItem('blueprint-queue-user', by); + addMutation.mutate({ typeId: selectedType.typeID, quantity, by }); + }, [selectedType, newQuantity, addedBy, addMutation]); + + if (error) { + return ( +
+

Failed to load queue: {(error as Error).message}

+ +
+ ); + } + + return ( +
+ {/* Add new item form */} +
+
+ {/* Search input with dropdown */} +
+ +
+ + { + setSearchQuery(e.target.value); + setSelectedType(null); + }} + onFocus={() => searchResults.length > 0 && setShowDropdown(true)} + className="h-9 pl-8" + /> +
+ {showDropdown && searchResults.length > 0 && ( +
+ {searchResults.map((hit) => ( + + ))} +
+ )} +
+
+ + setNewQuantity(e.target.value)} + className="h-9" + /> +
+
+ + setAddedBy(e.target.value)} + className="h-9" + /> +
+ + +
+
+ + {/* Queue table */} +
+ {isLoading ? ( +
+ +
+ ) : queue.length === 0 ? ( +
+ No blueprints in queue +
+ ) : ( + + + + + Blueprint + Qty + Added By + Actions + + + + {queue.map((item) => ( + + + + + +
+
+ {typeNames[item.type_id]?.typeName || `Type ${item.type_id}`} +
+
+ ID: {item.type_id} +
+
+
+ + {item.quantity ?? '—'} + + + {item.added_by} + + +
+ + +
+
+
+ ))} +
+
+ )} +
+
+ ); +}