Changes
This commit is contained in:
437
src/components/BlueprintsQueue.tsx
Normal file
437
src/components/BlueprintsQueue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user