This commit is contained in:
gpt-engineer-app[bot]
2026-01-12 17:50:33 +00:00
parent c04ddea706
commit e2fd151cad

View File

@@ -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<BlueprintQueueItem[]> {
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<void> {
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<void> {
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<void> {
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<Record<number, TypeInfo>>({});
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<number, TypeInfo> = {};
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<TypesenseHit[]> {
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<TypesenseHit[]>([]);
const [selectedType, setSelectedType] = useState<TypesenseHit | null>(null);
const [showDropdown, setShowDropdown] = useState(false);
const [newQuantity, setNewQuantity] = useState('');
const [addedBy, setAddedBy] = useState(() => localStorage.getItem('blueprint-queue-user') || '');
const dropdownRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(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 (
<div className="eve-card p-8 text-center">
<p className="text-destructive mb-4">Failed to load queue: {(error as Error).message}</p>
<Button onClick={() => refetch()} variant="outline">Retry</Button>
</div>
);
}
return (
<div className="space-y-4">
{/* Add new item form */}
<div className="eve-card p-4">
<div className="flex gap-3 items-end flex-wrap">
{/* Search input with dropdown */}
<div className="flex-1 min-w-[200px] relative" ref={dropdownRef}>
<label className="text-xs text-muted-foreground mb-1 block">Search Item</label>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
ref={searchInputRef}
placeholder="Search by name..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setSelectedType(null);
}}
onFocus={() => searchResults.length > 0 && setShowDropdown(true)}
className="h-9 pl-8"
/>
</div>
{showDropdown && searchResults.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-card border border-border rounded-md shadow-lg max-h-60 overflow-y-auto">
{searchResults.map((hit) => (
<button
key={hit.typeID}
onClick={() => handleSelectType(hit)}
className="w-full px-3 py-2 text-left hover:bg-muted flex items-center gap-2 text-sm"
>
<img
src={getTypeIconUrl(hit.typeID, 32)}
alt=""
className="w-6 h-6 rounded"
/>
<span>{hit.typeName}</span>
<span className="text-xs text-muted-foreground ml-auto">ID: {hit.typeID}</span>
</button>
))}
</div>
)}
</div>
<div className="w-24">
<label className="text-xs text-muted-foreground mb-1 block">Qty (opt)</label>
<Input
type="number"
placeholder="Qty"
value={newQuantity}
onChange={(e) => setNewQuantity(e.target.value)}
className="h-9"
/>
</div>
<div className="flex-1 min-w-[100px]">
<label className="text-xs text-muted-foreground mb-1 block">Added By</label>
<Input
placeholder="Your name"
value={addedBy}
onChange={(e) => setAddedBy(e.target.value)}
className="h-9"
/>
</div>
<Button
onClick={handleAdd}
disabled={!selectedType || addMutation.isPending}
className="h-9"
>
<Plus className="w-4 h-4 mr-1" />
Add
</Button>
<Button
onClick={() => refetch()}
variant="outline"
disabled={isFetching}
className="h-9"
>
<RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{/* Queue table */}
<div className="eve-card overflow-hidden">
{isLoading ? (
<div className="p-8 text-center">
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-primary" />
</div>
) : queue.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
No blueprints in queue
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12"></TableHead>
<TableHead>Blueprint</TableHead>
<TableHead className="w-20">Qty</TableHead>
<TableHead>Added By</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{queue.map((item) => (
<TableRow key={item.id}>
<TableCell>
<img
src={getTypeIconUrl(item.type_id, 32)}
alt=""
className="w-8 h-8 rounded"
/>
</TableCell>
<TableCell>
<div>
<div className="font-medium">
{typeNames[item.type_id]?.typeName || `Type ${item.type_id}`}
</div>
<div className="text-xs text-muted-foreground">
ID: {item.type_id}
</div>
</div>
</TableCell>
<TableCell>
{item.quantity ?? '—'}
</TableCell>
<TableCell className="text-muted-foreground">
{item.added_by}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-green-500 hover:text-green-400"
onClick={() => completeMutation.mutate(item.id)}
disabled={completeMutation.isPending}
title="Mark complete"
>
<Check className="w-4 h-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => removeMutation.mutate(item.id)}
disabled={removeMutation.isPending}
title="Remove"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
);
}