Merge tag '1.14.5_cleaned'
project was cleaned
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Camera, Check, RefreshCw, X, Image as ImageIcon, Sparkles, Hash, Layout, Layers, Package, ChevronDown } from 'lucide-react';
|
||||
import { useAIExtraction } from '@/hooks/useAIExtraction';
|
||||
// Image adjustment modal disabled - image editing to be implemented as future feature
|
||||
|
||||
interface AIOnboardingProps {
|
||||
onCancel: () => void;
|
||||
@@ -13,6 +14,7 @@ interface AIOnboardingProps {
|
||||
}
|
||||
|
||||
export default function AIOnboarding({ onCancel, onComplete, categories, inventory }: AIOnboardingProps) {
|
||||
|
||||
const {
|
||||
image,
|
||||
setImage,
|
||||
@@ -29,16 +31,27 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
fileInputRef,
|
||||
existingTypes,
|
||||
existingBoxes,
|
||||
extractedImageBlob,
|
||||
setExtractedImageBlob,
|
||||
startLiveCamera,
|
||||
stopLiveCamera,
|
||||
captureSnapshot,
|
||||
processImage,
|
||||
confirmSingleItem,
|
||||
confirmSingleItem: hookConfirmSingleItem,
|
||||
confirmAllItems: hookConfirmAllItems,
|
||||
updateEditingItem,
|
||||
handleFileChange
|
||||
} = useAIExtraction(inventory, onComplete);
|
||||
|
||||
const confirmSingleItem = (index: number) => {
|
||||
if (!extractedItems[index]) return;
|
||||
|
||||
// Skip image adjustment modal - just save original image
|
||||
// Image editing will be implemented as a future feature
|
||||
hookConfirmSingleItem(index);
|
||||
};
|
||||
|
||||
|
||||
const confirmAllItems = async () => {
|
||||
await hookConfirmAllItems();
|
||||
onCancel(); // Close modal after bulk completion
|
||||
@@ -52,8 +65,8 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
<Sparkles className="text-primary w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold">AI Discovery</h2>
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted font-bold">Powered by Gemini 2.0 Flash</p>
|
||||
<h2 className="text-lg font-normal">AI Discovery</h2>
|
||||
<p className="text-xs text-muted font-normal">Powered by Gemini 2.0 Flash</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -66,12 +79,12 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
</div>
|
||||
|
||||
{!image && !isLive ? (
|
||||
<div className="flex-1 flex flex-col gap-6 min-h-0">
|
||||
<div className="flex-1 flex flex-col gap-3 md:gap-4 min-h-0">
|
||||
<div data-testid="multi-item-toggle" className="flex bg-surface/70 p-1.5 rounded-2xl border border-slate-800/50 shrink-0">
|
||||
<button
|
||||
onClick={() => setMode('item')}
|
||||
aria-label="Select Discovery Mode"
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 lg:py-4 xl:py-5 rounded-xl font-bold cursor-pointer transition-all focus:ring-2 focus:ring-primary focus:outline-none ${mode === 'item' ? 'bg-primary text-white shadow-lg' : 'text-muted hover:text-secondary'}`}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-normal cursor-pointer transition-all focus:ring-2 focus:ring-primary focus:outline-none ${mode === 'item' ? 'bg-primary text-white shadow-lg' : 'text-muted hover:text-secondary'}`}
|
||||
>
|
||||
<Package size={18} />
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl">Discovery Mode</span>
|
||||
@@ -79,7 +92,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
<button
|
||||
onClick={() => setMode('box')}
|
||||
aria-label="Select Box Lookup mode"
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 lg:py-4 xl:py-5 rounded-xl font-bold cursor-pointer transition-all focus:ring-2 focus:ring-primary focus:outline-none ${mode === 'box' ? 'bg-primary text-white shadow-lg' : 'text-muted hover:text-secondary'}`}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-normal cursor-pointer transition-all focus:ring-2 focus:ring-primary focus:outline-none ${mode === 'box' ? 'bg-primary text-white shadow-lg' : 'text-muted hover:text-secondary'}`}
|
||||
>
|
||||
<Layers size={18} />
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl">Box Lookup</span>
|
||||
@@ -90,10 +103,10 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
<div className="w-20 h-20 bg-surface rounded-3xl flex items-center justify-center mb-6 shadow-inner text-primary">
|
||||
<Camera size={32} />
|
||||
</div>
|
||||
<p className="text-secondary mb-2 text-center font-bold">
|
||||
<p className="text-secondary mb-2 text-center font-normal">
|
||||
{mode === 'box' ? 'Deep Box Analysis' : 'Multi-Item Extraction'}
|
||||
</p>
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted px-8 text-center leading-relaxed font-bold">
|
||||
<p className="text-xs text-muted px-8 text-center leading-relaxed font-normal">
|
||||
{mode === 'box'
|
||||
? 'Scan container labels to identify storage locations'
|
||||
: 'Identify multiple technical items from a single photo or label'}
|
||||
@@ -104,7 +117,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
<button
|
||||
onClick={startLiveCamera}
|
||||
aria-label="Start camera scan"
|
||||
className="flex flex-col items-center justify-center gap-2 bg-primary text-white rounded-3xl font-bold shadow-2xl shadow-primary/20 cursor-pointer active:scale-95 transition-all focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||
className="flex flex-col items-center justify-center gap-2 bg-primary text-white rounded-3xl font-normal shadow-2xl shadow-primary/20 cursor-pointer active:scale-95 transition-all focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||
>
|
||||
<Camera size={24} />
|
||||
<span className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl">Scan Camera</span>
|
||||
@@ -113,7 +126,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
data-testid="manual-entry-tab"
|
||||
aria-label="Upload photo from device"
|
||||
className="flex flex-col items-center justify-center gap-2 bg-surface text-secondary border border-slate-800 rounded-3xl font-bold cursor-pointer active:scale-95 transition-all focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
className="flex flex-col items-center justify-center gap-2 bg-surface text-secondary border border-slate-800 rounded-3xl font-normal cursor-pointer active:scale-95 transition-all focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<ImageIcon size={24} />
|
||||
<span className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl">Upload Photo</span>
|
||||
@@ -124,7 +137,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
</div>
|
||||
) : isLive ? (
|
||||
// LIVE VIEWFINDER MODE
|
||||
<div data-testid="wizard-step wizard-step-capture" className="flex-1 flex flex-col gap-6 min-h-0 overflow-hidden">
|
||||
<div data-testid="wizard-step wizard-step-capture" className="flex-1 flex flex-col gap-3 md:gap-4 min-h-0 overflow-hidden">
|
||||
<div data-testid="capture-camera" className="relative flex-1 min-h-0 rounded-[2.5rem] overflow-hidden border-4 border-slate-900 shadow-2xl bg-black">
|
||||
<video
|
||||
ref={videoRef}
|
||||
@@ -145,8 +158,8 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-6 left-1/2 -translate-x-1/2 bg-black/60 px-4 lg:px-5 xl:px-6 py-1.5 rounded-full border border-white/10">
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-white flex items-center gap-2">
|
||||
<div className="absolute top-6 left-1/2 -translate-x-1/2 bg-black/60 px-4 py-1.5 rounded-full border border-white/10">
|
||||
<span className="text-xs font-normal text-white flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
|
||||
Live Viewfinder
|
||||
</span>
|
||||
@@ -157,7 +170,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
<button
|
||||
onClick={stopLiveCamera}
|
||||
aria-label="Cancel camera scan"
|
||||
className="px-6 py-4 bg-surface border border-slate-800 text-secondary rounded-2xl font-bold cursor-pointer transition-all active:scale-95 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
className="px-6 py-4 bg-surface border border-slate-800 text-secondary rounded-2xl font-normal cursor-pointer transition-all active:scale-95 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -165,7 +178,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
onClick={captureSnapshot}
|
||||
data-testid="capture-button"
|
||||
aria-label="Capture image from camera"
|
||||
className="flex-1 py-4 bg-white text-slate-950 rounded-2xl font-black text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl shadow-xl shadow-white/10 cursor-pointer flex items-center justify-center gap-3 active:scale-95 hover:bg-slate-200 transition-all focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-950 focus:ring-white focus:outline-none"
|
||||
className="flex-1 py-4 bg-white text-slate-950 rounded-2xl font-normal text-lg shadow-xl shadow-white/10 cursor-pointer flex items-center justify-center gap-3 active:scale-95 hover:bg-slate-200 transition-all focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-950 focus:ring-white focus:outline-none"
|
||||
>
|
||||
<div className="w-5 h-5 rounded-full border-2 border-slate-950 flex items-center justify-center">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-background" />
|
||||
@@ -175,7 +188,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
</div>
|
||||
</div>
|
||||
) : extractedItems.length === 0 ? (
|
||||
<div data-testid="wizard-step" className="flex-1 flex flex-col gap-6 min-h-0 overflow-hidden">
|
||||
<div data-testid="wizard-step" className="flex-1 flex flex-col gap-3 md:gap-4 min-h-0 overflow-hidden">
|
||||
<div className="relative flex-1 min-h-0 rounded-[2.5rem] overflow-hidden border-4 border-slate-900 shadow-2xl bg-surface">
|
||||
<img src={image || undefined} className="w-full h-full object-contain" alt="Captured label" />
|
||||
<div className="absolute inset-0 bg-black/30 pointer-events-none" />
|
||||
@@ -186,7 +199,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
<RefreshCw className="w-12 h-12 text-primary animate-spin" />
|
||||
<Sparkles className="absolute -top-2 -right-2 text-amber-400 w-6 h-6 animate-pulse" />
|
||||
</div>
|
||||
<p className="text-white font-black tracking-tight text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl">Gemini is processing...</p>
|
||||
<p className="text-white font-normal tracking-tight text-sm">Gemini is processing...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -197,7 +210,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
disabled={uploading}
|
||||
data-testid="retake-button"
|
||||
aria-label="Retake photo"
|
||||
className="px-6 py-4 bg-surface border border-slate-800 text-secondary rounded-2xl font-bold cursor-pointer transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
className="px-6 py-4 bg-surface border border-slate-800 text-secondary rounded-2xl font-normal cursor-pointer transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
Retake
|
||||
</button>
|
||||
@@ -205,7 +218,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
onClick={processImage}
|
||||
disabled={uploading}
|
||||
aria-label="Extract data from image"
|
||||
className="flex-1 py-4 bg-primary text-white rounded-2xl font-black text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl shadow-xl shadow-primary/30 cursor-pointer flex items-center justify-center gap-3 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-blue-500 transition-all focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||
className="flex-1 py-4 bg-primary text-white rounded-2xl font-normal text-lg shadow-xl shadow-primary/30 cursor-pointer flex items-center justify-center gap-3 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-blue-500 transition-all focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||
>
|
||||
{uploading ? "Analyzing..." : "Extract Data"}
|
||||
{!uploading && <Check size={20} />}
|
||||
@@ -213,38 +226,66 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
</div>
|
||||
</div>
|
||||
) : editingIndex !== null ? (
|
||||
<div data-testid="extraction-results-form" className="flex-1 flex flex-col gap-6 overflow-hidden">
|
||||
<div data-testid="extraction-results-form" className="flex-1 flex flex-col gap-3 md:gap-4 overflow-hidden">
|
||||
<div data-testid="ai-onboarding-wizard" className="flex items-center justify-between">
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted font-bold">Item Details (<span data-testid="current-step">{editingIndex + 1}</span>/<span data-testid="total-steps">{extractedItems.length}</span>)</span>
|
||||
<span className="text-xs text-muted font-normal">Item Details (<span data-testid="current-step">{editingIndex + 1}</span>/<span data-testid="total-steps">{extractedItems.length}</span>)</span>
|
||||
<button
|
||||
onClick={() => setEditingIndex(null)}
|
||||
aria-label="Back to item list"
|
||||
className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-primary font-bold px-3 lg:px-4 xl:px-5 py-1 bg-primary/10 rounded-full cursor-pointer transition-colors focus:ring-2 focus:ring-primary focus:outline-none"
|
||||
className="text-xs text-primary font-normal px-3 py-1 bg-primary/10 rounded-full cursor-pointer transition-colors focus:ring-2 focus:ring-primary focus:outline-none"
|
||||
>
|
||||
Back to List
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Image Preview Section */}
|
||||
{image && (
|
||||
<div className="bg-surface/70 border border-slate-800/50 rounded-2xl overflow-hidden">
|
||||
<div className="relative h-40 md:h-48 bg-black/20 flex items-center justify-center">
|
||||
<img
|
||||
src={image}
|
||||
alt="Extracted label"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3 space-y-2">
|
||||
<p className="text-xs text-muted font-normal">Extracted Photo</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="save-photo-check"
|
||||
checked={!extractedItems[editingIndex]._skipPhoto}
|
||||
onChange={(e) => updateEditingItem({ _skipPhoto: !e.target.checked })}
|
||||
className="w-4 h-4 rounded border-slate-600 accent-primary cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="save-photo-check" className="text-xs text-slate-300 font-normal cursor-pointer">
|
||||
Save this photo with the item
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-1 scrollbar-hide">
|
||||
<div data-testid="extracted-name" className="bg-surface py-2 lg:py-3 xl:py-4.5 px-4 lg:px-5 xl:px-6 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Item Name</label>
|
||||
<div data-testid="extracted-name" className="bg-surface py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-muted font-normal mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Item Name</label>
|
||||
<textarea
|
||||
value={extractedItems[editingIndex].Item || extractedItems[editingIndex].name || ''}
|
||||
onChange={(e) => updateEditingItem({ Item: e.target.value })}
|
||||
className="bg-transparent w-full text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold outline-none text-white placeholder:text-muted resize-none h-8 leading-tight selection:bg-primary/30 py-0"
|
||||
className="bg-transparent w-full text-lg font-normal outline-none text-white placeholder:text-muted resize-none h-8 leading-tight selection:bg-primary/30 py-0"
|
||||
placeholder="SSD, SFP, Cable..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div data-testid="extracted-category" className="bg-surface py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Category</label>
|
||||
<div data-testid="extracted-category" className="bg-surface py-3 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-muted font-normal mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Category</label>
|
||||
<input
|
||||
type="text"
|
||||
list="onboarding-categories"
|
||||
value={extractedItems[editingIndex].Category || extractedItems[editingIndex].category || ''}
|
||||
onChange={(e) => updateEditingItem({ Category: e.target.value })}
|
||||
className="bg-transparent w-full font-bold outline-none text-secondary"
|
||||
className="bg-transparent w-full font-normal outline-none text-secondary"
|
||||
placeholder="e.g. storage"
|
||||
/>
|
||||
<datalist id="onboarding-categories">
|
||||
@@ -253,13 +294,13 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="bg-surface py-2 lg:py-3 xl:py-4.5 px-4 lg:px-5 xl:px-6 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Item Type</label>
|
||||
<div className="bg-surface py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-muted font-normal mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Item Type</label>
|
||||
<input
|
||||
value={extractedItems[editingIndex].Type || extractedItems[editingIndex].type || ''}
|
||||
list="onboarding-types"
|
||||
onChange={(e) => updateEditingItem({ Type: e.target.value })}
|
||||
className="bg-transparent w-full text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold outline-none text-secondary"
|
||||
className="bg-transparent w-full text-sm font-normal outline-none text-secondary"
|
||||
placeholder="e.g. spare parts"
|
||||
/>
|
||||
<datalist id="onboarding-types">
|
||||
@@ -269,22 +310,22 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-surface py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Item Color</label>
|
||||
<div className="bg-surface py-3 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-muted font-normal mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Item Color</label>
|
||||
<input
|
||||
value={extractedItems[editingIndex].Color || extractedItems[editingIndex].color || ''}
|
||||
onChange={(e) => updateEditingItem({ Color: e.target.value })}
|
||||
className="bg-transparent w-full font-bold outline-none text-secondary"
|
||||
className="bg-transparent w-full font-normal outline-none text-secondary"
|
||||
placeholder="e.g. Aqua"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-surface py-2 lg:py-3 xl:py-4.5 px-4 lg:px-5 xl:px-6 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Box / Container</label>
|
||||
<div className="bg-surface py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-muted font-normal mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Box / Container</label>
|
||||
<input
|
||||
value={extractedItems[editingIndex].box_label || ''}
|
||||
list="onboarding-boxes"
|
||||
onChange={(e) => updateEditingItem({ box_label: e.target.value })}
|
||||
className="bg-transparent w-full text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold outline-none text-primary"
|
||||
className="bg-transparent w-full text-sm font-normal outline-none text-primary"
|
||||
placeholder="e.g. Box 1"
|
||||
/>
|
||||
<datalist id="onboarding-boxes">
|
||||
@@ -293,8 +334,8 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface py-2 lg:py-3 xl:py-4.5 px-4 lg:px-5 xl:px-6 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Description</label>
|
||||
<div className="bg-surface py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-muted font-normal mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Description</label>
|
||||
<textarea
|
||||
value={extractedItems[editingIndex].Description || extractedItems[editingIndex].description || ''}
|
||||
onChange={(e) => updateEditingItem({ Description: e.target.value })}
|
||||
@@ -304,53 +345,53 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-surface py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Connector</label>
|
||||
<div className="bg-surface py-3 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-muted font-normal mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Connector</label>
|
||||
<input
|
||||
value={extractedItems[editingIndex].Connector || extractedItems[editingIndex].connector || ''}
|
||||
onChange={(e) => updateEditingItem({ Connector: e.target.value })}
|
||||
className="bg-transparent w-full font-bold outline-none text-secondary"
|
||||
className="bg-transparent w-full font-normal outline-none text-secondary"
|
||||
placeholder="e.g. LC/UPC"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-surface px-4 lg:px-5 xl:px-6 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Size / Length</label>
|
||||
<div className="bg-surface px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-muted font-normal mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Size / Length</label>
|
||||
<input
|
||||
value={extractedItems[editingIndex].Size || extractedItems[editingIndex].size || ''}
|
||||
onChange={(e) => updateEditingItem({ Size: e.target.value })}
|
||||
className="bg-transparent w-full text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold outline-none text-secondary"
|
||||
className="bg-transparent w-full text-sm font-normal outline-none text-secondary"
|
||||
placeholder="e.g. 5m / 10G"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface py-2 lg:py-3 xl:py-4.5 px-4 lg:px-5 xl:px-6 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">OCR Matching Key</label>
|
||||
<div className="bg-surface py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-muted font-normal mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">OCR Matching Key</label>
|
||||
<textarea
|
||||
value={extractedItems[editingIndex].OCR || extractedItems[editingIndex].ocr_text || ''}
|
||||
onChange={(e) => updateEditingItem({ OCR: e.target.value })}
|
||||
className="bg-transparent w-full text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold leading-tight outline-none resize-none h-10 text-secondary py-0 scrollbar-hide"
|
||||
className="bg-transparent w-full text-sm font-normal leading-tight outline-none resize-none h-10 text-secondary py-0 scrollbar-hide"
|
||||
placeholder="Heuristic string for local matching..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="bg-surface py-2 lg:py-3 xl:py-4.5 px-4 lg:px-5 xl:px-6 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Part Number</label>
|
||||
<div className="bg-surface py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-muted font-normal mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Part Number</label>
|
||||
<input
|
||||
value={extractedItems[editingIndex].PartNr || extractedItems[editingIndex].part_number || ''}
|
||||
onChange={(e) => updateEditingItem({ PartNr: e.target.value })}
|
||||
className="bg-transparent w-full font-mono text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold outline-none text-secondary"
|
||||
className="bg-transparent w-full font-mono text-sm font-normal outline-none text-secondary"
|
||||
placeholder="ID code..."
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-surface py-2 lg:py-3 xl:py-4.5 px-4 lg:px-5 xl:px-6 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tighter">Initial Qty</label>
|
||||
<div className="bg-surface py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-muted font-normal mb-0.5 block group-focus-within:text-primary transition-colors tracking-tighter">Initial Qty</label>
|
||||
<input
|
||||
type="number"
|
||||
value={extractedItems[editingIndex].quantity || 1}
|
||||
onChange={(e) => updateEditingItem({ quantity: parseFloat(e.target.value) })}
|
||||
className="bg-transparent w-full font-black text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl outline-none text-secondary"
|
||||
className="bg-transparent w-full font-normal text-base outline-none text-secondary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -361,7 +402,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
onClick={() => confirmSingleItem(editingIndex)}
|
||||
data-testid="confirm-extraction"
|
||||
aria-label="Add item to catalog"
|
||||
className="py-5 bg-primary text-white rounded-[1.8rem] font-black text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl shadow-2xl shadow-primary/20 cursor-pointer active:scale-95 transition-all hover:bg-blue-500 focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||
className="py-5 bg-primary text-white rounded-[1.8rem] font-normal text-lg shadow-2xl shadow-primary/20 cursor-pointer active:scale-95 transition-all hover:bg-blue-500 focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||
>
|
||||
Add to Catalog
|
||||
</button>
|
||||
@@ -370,8 +411,8 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
) : (
|
||||
<div data-testid="manual-entry-form" className="flex-1 flex flex-col gap-6 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black text-secondary">Discovery Dashboard</h3>
|
||||
<span data-testid="wizard-progress" className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl bg-primary/20 text-primary px-3 lg:px-4 xl:px-5 py-1 rounded-full font-bold">
|
||||
<h3 className="text-sm font-normal text-secondary">Discovery Dashboard</h3>
|
||||
<span data-testid="wizard-progress" className="text-xs bg-primary/20 text-primary px-3 py-1 rounded-full font-normal">
|
||||
{extractedItems.length} items found
|
||||
</span>
|
||||
</div>
|
||||
@@ -400,13 +441,13 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
<div className="w-8 h-8 rounded-xl bg-primary/10 flex items-center justify-center text-primary">
|
||||
<Package size={16} />
|
||||
</div>
|
||||
<h4 className="font-black text-secondary truncate">{item.Item || item.name || "Unknown Item"}</h4>
|
||||
<h4 className="font-normal text-secondary truncate">{item.Item || item.name || "Unknown Item"}</h4>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black px-2 py-0.5 bg-slate-800 text-secondary rounded-md">
|
||||
<span className="text-xs font-normal px-2 py-0.5 bg-slate-800 text-secondary rounded-md">
|
||||
{item.Type || item.type || "Generic"}
|
||||
</span>
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black px-2 py-0.5 bg-slate-800 text-muted rounded-md">
|
||||
<span className="text-xs font-normal px-2 py-0.5 bg-slate-800 text-muted rounded-md">
|
||||
{item.PartNr || item.part_number || "No P/N"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -440,7 +481,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
<button
|
||||
onClick={confirmAllItems}
|
||||
aria-label={`Add ${extractedItems.length} items to catalog`}
|
||||
className="py-5 bg-white text-slate-950 rounded-[1.8rem] font-black text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl shadow-2xl shadow-white/10 cursor-pointer active:scale-95 transition-all hover:bg-slate-200 focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-950 focus:ring-white focus:outline-none"
|
||||
className="py-5 bg-white text-slate-950 rounded-[1.8rem] font-normal text-lg shadow-2xl shadow-white/10 cursor-pointer active:scale-95 transition-all hover:bg-slate-200 focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-950 focus:ring-white focus:outline-none"
|
||||
>
|
||||
Add {extractedItems.length} items to catalog
|
||||
</button>
|
||||
@@ -451,13 +492,14 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
}}
|
||||
data-testid="reject-extraction"
|
||||
aria-label="Discard discovery session"
|
||||
className="py-3 lg:py-4 xl:py-5 text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-secondary font-bold cursor-pointer hover:text-secondary transition-colors focus:ring-2 focus:ring-slate-500 focus:outline-none rounded px-2"
|
||||
className="py-3 text-xs text-secondary font-normal cursor-pointer hover:text-secondary transition-colors focus:ring-2 focus:ring-slate-500 focus:outline-none rounded px-2"
|
||||
>
|
||||
Discard Discovery
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,12 +115,12 @@ export default function AdminOverlay({
|
||||
/>
|
||||
<div data-testid="admin-overlay" className="fixed inset-0 z-50 bg-background/60 animate-in fade-in duration-300">
|
||||
<div className="absolute inset-y-0 right-0 w-full max-w-md sm:max-w-lg md:max-w-md bg-surface border-l border-slate-800 shadow-2xl flex flex-col animate-in slide-in-from-right duration-500">
|
||||
<div className="p-6 lg:p-8 xl:p-10 border-b border-slate-800 flex items-center justify-between">
|
||||
<div className="p-3 md:p-6 border-b border-slate-800 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-slate-800 rounded-xl text-primary">
|
||||
<Shield size={20} />
|
||||
</div>
|
||||
<h2 className="text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-white">System Admin</h2>
|
||||
<h2 className="text-xl font-normal text-white">System Admin</h2>
|
||||
</div>
|
||||
<button
|
||||
data-testid="close-admin-overlay"
|
||||
@@ -132,31 +132,31 @@ export default function AdminOverlay({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 lg:p-8 xl:p-10 space-y-8">
|
||||
<section className="space-y-4">
|
||||
<div className="flex-1 overflow-y-auto p-3 md:p-6 space-y-3 md:space-y-4">
|
||||
<section className="space-y-2 md:space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black text-secondary">User Management</h3>
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted mt-0.5">Create accounts and control access</p>
|
||||
<h3 className="text-sm font-normal text-secondary">User Management</h3>
|
||||
<p className="text-xs text-muted mt-0.5">Create accounts and control access</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateUserModal(true)}
|
||||
className="flex items-center gap-1 text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-primary hover:text-blue-400 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none rounded px-1"
|
||||
className="flex items-center gap-1 text-xs font-normal text-primary hover:text-blue-400 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none rounded px-1"
|
||||
>
|
||||
<UserPlus size={12} /> Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-1.5 md:gap-2">
|
||||
{users.map(u => (
|
||||
<div key={u.id} className="bg-slate-800/40 border border-slate-800 p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-lg ${u.role === 'admin' ? "bg-primary/20 text-primary" : "bg-slate-700 text-secondary"}`}>
|
||||
<div key={u.id} className="bg-slate-800/40 border border-slate-800 p-3 md:p-4 rounded-2xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<div className={`p-2 rounded-lg ${u.role === 'admin' ? "bg-primary/20 text-primary" : "bg-slate-700 text-secondary"}`}>
|
||||
<User size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-white">{u.username}</p>
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-muted">{u.role}</p>
|
||||
<p className="text-sm font-normal text-white">{u.username}</p>
|
||||
<p className="text-xs font-normal text-muted">{u.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -182,30 +182,30 @@ export default function AdminOverlay({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<section className="space-y-2 md:space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black text-secondary">Category Groups</h3>
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted mt-0.5">Organize items by type or location</p>
|
||||
<h3 className="text-sm font-normal text-secondary">Category Groups</h3>
|
||||
<p className="text-xs text-muted mt-0.5">Organize items by type or location</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateCategoryModal(true)}
|
||||
className="flex items-center gap-1 text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-primary hover:text-blue-400 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none rounded px-1"
|
||||
className="flex items-center gap-1 text-xs font-normal text-primary hover:text-blue-400 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none rounded px-1"
|
||||
>
|
||||
<Plus size={12} /> Add Category
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
|
||||
<div className="grid gap-1.5 md:gap-2">
|
||||
{categories.map(cat => (
|
||||
<div key={cat.id} className="bg-slate-800/40 border border-slate-800 p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-primary/10 rounded-lg text-primary">
|
||||
<div key={cat.id} className="bg-slate-800/40 border border-slate-800 p-3 md:p-4 rounded-2xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg text-primary">
|
||||
<Layers size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-white">{cat.name}</p>
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted font-mono">{cat.description || 'No description'}</p>
|
||||
<p className="text-sm font-normal text-white">{cat.name}</p>
|
||||
<p className="text-xs text-muted font-mono">{cat.description || 'No description'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -230,10 +230,10 @@ export default function AdminOverlay({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="p-6 lg:p-8 xl:p-10 bg-slate-800/30 rounded-3xl border border-slate-800 space-y-4">
|
||||
<section className="p-4 md:p-6 bg-slate-800/30 rounded-3xl border border-slate-800 space-y-2 md:space-y-3">
|
||||
<div className="flex items-center gap-2 text-rose-500">
|
||||
<AlertTriangle size={16} />
|
||||
<p className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black">Sign Out</p>
|
||||
<p className="text-sm font-normal">Sign Out</p>
|
||||
</div>
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted">End your session and return to the login screen.</p>
|
||||
<button
|
||||
@@ -241,7 +241,7 @@ export default function AdminOverlay({
|
||||
import('@/lib/auth').then(m => m.clearAuth());
|
||||
window.location.href = '/login';
|
||||
}}
|
||||
className="w-full bg-rose-500/10 hover:bg-rose-500/20 text-rose-500 border border-rose-500/20 font-black py-3 lg:py-4 xl:py-5 rounded-xl transition-all flex items-center justify-center gap-2 focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:outline-none"
|
||||
className="w-full bg-rose-500/10 hover:bg-rose-500/20 text-rose-500 border border-rose-500/20 font-normal py-3 rounded-xl transition-all flex items-center justify-center gap-2 focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:outline-none"
|
||||
>
|
||||
<LogOut size={16} /> Sign Out
|
||||
</button>
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function BottomNav({
|
||||
className={cn("flex flex-col items-center gap-1 rounded px-2 py-1 cursor-pointer transition-colors focus:ring-2 focus:ring-primary focus:outline-none", isHome && "text-primary")}
|
||||
>
|
||||
<Smartphone size={20} />
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold transition-all">Home</span>
|
||||
<span className="text-xs font-normal transition-all">Home</span>
|
||||
</button>
|
||||
|
||||
{/* Inventory */}
|
||||
@@ -44,7 +44,7 @@ export default function BottomNav({
|
||||
className={cn("flex flex-col items-center gap-1 rounded px-2 py-1 cursor-pointer transition-colors focus:ring-2 focus:ring-primary focus:outline-none", isInventory && "text-primary")}
|
||||
>
|
||||
<Package size={20} />
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold transition-all">Inventory</span>
|
||||
<span className="text-xs font-normal transition-all">Inventory</span>
|
||||
</button>
|
||||
|
||||
{/* Logs */}
|
||||
@@ -54,7 +54,7 @@ export default function BottomNav({
|
||||
className={cn("flex flex-col items-center gap-1 rounded px-2 py-1 cursor-pointer transition-colors focus:ring-2 focus:ring-primary focus:outline-none", isLogs && "text-primary")}
|
||||
>
|
||||
<History size={20} />
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold transition-all">Logs</span>
|
||||
<span className="text-xs font-normal transition-all">Logs</span>
|
||||
</button>
|
||||
|
||||
{/* Admin Settings */}
|
||||
@@ -66,7 +66,7 @@ export default function BottomNav({
|
||||
className={cn("flex flex-col items-center gap-1 rounded px-2 py-1 cursor-pointer transition-colors focus:ring-2 focus:ring-primary focus:outline-none", isAdmin && "text-primary")}
|
||||
>
|
||||
<Settings size={20} />
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold transition-all">Admin</span>
|
||||
<span className="text-xs font-normal transition-all">Admin</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function BottomNav({
|
||||
className="flex flex-col items-center gap-1 text-rose-500 hover:text-rose-400 cursor-pointer transition-colors rounded px-2 py-1 focus:ring-2 focus:ring-rose-500 focus:outline-none"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold transition-all">Logout</span>
|
||||
<span className="text-xs font-normal transition-all">Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function CameraView({
|
||||
const cn = (...classes: any[]) => classes.filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto flex flex-col gap-6">
|
||||
<div className="w-full max-w-md mx-auto flex flex-col gap-3 md:gap-4">
|
||||
{/* Video Viewport Area */}
|
||||
<div className="relative w-full aspect-square overflow-hidden rounded-[2.5rem] shadow-[0_20px_50px_rgba(0,0,0,0.5)] bg-background border-2 border-slate-800/50 p-4 sm:p-6 lg:p-8 xl:p-10">
|
||||
<div className="absolute inset-4 sm:inset-6 z-10 pointer-events-none flex items-center justify-center">
|
||||
@@ -91,13 +91,13 @@ export default function CameraView({
|
||||
</div>
|
||||
<div className="p-6 lg:p-8 xl:p-10 bg-surface/90 border-t border-slate-800 flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black text-white italic text-center">Text Found</p>
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted font-black text-center">Tap any text to use it</p>
|
||||
<p className="text-sm font-normal text-white italic text-center">Text Found</p>
|
||||
<p className="text-xs text-muted font-normal text-center">Tap any text to use it</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancelSelection}
|
||||
aria-label="Cancel text selection"
|
||||
className="w-full py-4 bg-slate-800 hover:bg-slate-700 text-white rounded-2xl font-black text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl cursor-pointer transition-all active:scale-95 border border-slate-700 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
className="w-full py-4 bg-slate-800 hover:bg-slate-700 text-white rounded-2xl font-normal text-xs cursor-pointer transition-all active:scale-95 border border-slate-700 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -116,13 +116,13 @@ export default function CameraView({
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-surface text-secondary px-8 text-center gap-4">
|
||||
<XCircle className="w-10 h-10 text-red-500" />
|
||||
<div>
|
||||
<p className="font-bold text-white">Camera Error</p>
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-secondary mt-1">{error}</p>
|
||||
<p className="font-normal text-white">Camera Error</p>
|
||||
<p className="text-xs text-secondary mt-1">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
aria-label="Reload page and try again"
|
||||
className="mt-4 px-6 py-2 lg:py-3 xl:py-4 bg-slate-800 rounded-full text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold cursor-pointer hover:bg-slate-700 transition-colors focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
className="mt-4 px-6 py-2 bg-slate-800 rounded-full text-sm font-normal cursor-pointer hover:bg-slate-700 transition-colors focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
@@ -148,8 +148,8 @@ export default function CameraView({
|
||||
aria-label={`Zoom ${zoom.toFixed(1)}x`}
|
||||
className="h-14 px-5 bg-slate-800/80 hover:bg-slate-700 border border-slate-700 text-white rounded-2xl flex flex-col items-center justify-center shadow-lg cursor-pointer transition-all active:scale-95 shrink-0 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black tabular-nums">{zoom.toFixed(1)}x</span>
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-primary font-black tracking-tighter">Zoom</span>
|
||||
<span className="text-xs font-normal tabular-nums">{zoom.toFixed(1)}x</span>
|
||||
<span className="text-xs text-primary font-normal tracking-tighter">Zoom</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -157,7 +157,7 @@ export default function CameraView({
|
||||
{ocrProcessing ? (
|
||||
<>
|
||||
<RefreshCw className="animate-spin text-primary" size={18} />
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-secondary leading-none">Analyzing</span>
|
||||
<span className="text-xs font-normal text-secondary leading-none">Analyzing</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -166,8 +166,8 @@ export default function CameraView({
|
||||
size={18}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted font-black leading-none">Smart Scan</span>
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-primary leading-tight tabular-nums">
|
||||
<span className="text-xs text-muted font-normal leading-none">Smart Scan</span>
|
||||
<span className="text-xs font-normal text-primary leading-tight tabular-nums">
|
||||
{countdown === 0 ? 'Scanning' : `${countdown}s`}
|
||||
</span>
|
||||
</div>
|
||||
@@ -184,7 +184,7 @@ export default function CameraView({
|
||||
|
||||
<div className="w-full flex justify-center items-center gap-2">
|
||||
<div className="w-1 h-1 rounded-full bg-green-500 animate-pulse" />
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-secondary font-black">
|
||||
<p className="text-xs text-secondary font-normal">
|
||||
Scanner active · Use zoom or tap scan
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function CategoryCreationModal({
|
||||
<div className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-primary/10 rounded-lg text-primary">
|
||||
<Tag size={20} />
|
||||
</div>
|
||||
<h2 className="text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black text-white">New Category</h2>
|
||||
<h2 className="text-lg font-normal text-white">New Category</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -71,7 +71,7 @@ export default function CategoryCreationModal({
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 lg:p-8 xl:p-10 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black text-secondary">Name</label>
|
||||
<label className="text-sm font-normal text-secondary">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
@@ -87,7 +87,7 @@ export default function CategoryCreationModal({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black text-secondary">Description (optional)</label>
|
||||
<label className="text-sm font-normal text-secondary">Description (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
@@ -109,14 +109,14 @@ export default function CategoryCreationModal({
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 lg:px-5 xl:px-6 py-2 lg:py-3 xl:py-4.5 text-secondary bg-slate-800 border border-slate-700 rounded font-semibold hover:bg-slate-700 transition-colors disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
||||
className="flex-1 px-4 py-2.5 text-secondary bg-slate-800 border border-slate-700 rounded font-normal hover:bg-slate-700 transition-colors disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !name.trim()}
|
||||
className="flex-1 px-4 lg:px-5 xl:px-6 py-2 lg:py-3 xl:py-4.5 bg-primary text-white rounded font-semibold hover:bg-blue-600 transition-colors disabled:opacity-50 flex items-center justify-center gap-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900 focus-visible:ring-primary focus-visible:outline-none"
|
||||
className="flex-1 px-4 py-2.5 bg-primary text-white rounded font-normal hover:bg-blue-600 transition-colors disabled:opacity-50 flex items-center justify-center gap-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900 focus-visible:ring-primary focus-visible:outline-none"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function ConfirmationModal({
|
||||
<div className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-rose-500/10 rounded-lg text-rose-500">
|
||||
<AlertTriangle size={20} />
|
||||
</div>
|
||||
<h2 className="text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black text-white">{title}</h2>
|
||||
<h2 className="text-lg font-normal text-white">{title}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
@@ -81,9 +81,9 @@ export default function ConfirmationModal({
|
||||
|
||||
{/* Item Name (if provided) */}
|
||||
{itemName && (
|
||||
<div className="bg-slate-800/40 border border-slate-800 rounded p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8">
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted font-semibold">Item</p>
|
||||
<p className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-mono text-white mt-1">{itemName}</p>
|
||||
<div className="bg-slate-800/40 border border-slate-800 rounded p-3">
|
||||
<p className="text-xs text-muted font-normal">Item</p>
|
||||
<p className="text-sm font-mono text-white mt-1">{itemName}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function ConfirmationModal({
|
||||
{/* High-Risk Warning & Confirmation Input */}
|
||||
{requiresTextConfirm && (
|
||||
<div className="space-y-3 pt-2 border-t border-slate-800">
|
||||
<p className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-semibold text-rose-400">
|
||||
<p className="text-sm font-normal text-rose-400">
|
||||
Type "DELETE" to confirm this high-risk action
|
||||
</p>
|
||||
<input
|
||||
@@ -138,7 +138,7 @@ export default function ConfirmationModal({
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 lg:px-5 xl:px-6 py-2 lg:py-3 xl:py-4.5 text-secondary bg-slate-800 border border-slate-700 rounded font-semibold hover:bg-slate-700 cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus:ring-2 focus:ring-primary focus:outline-none"
|
||||
className="flex-1 px-4 py-2.5 text-secondary bg-slate-800 border border-slate-700 rounded font-normal hover:bg-slate-700 cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus:ring-2 focus:ring-primary focus:outline-none"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -146,7 +146,7 @@ export default function ConfirmationModal({
|
||||
onClick={handleConfirm}
|
||||
data-testid="confirm-action"
|
||||
disabled={loading || !canConfirm}
|
||||
className="flex-1 px-4 lg:px-5 xl:px-6 py-2 lg:py-3 xl:py-4.5 bg-rose-600 text-white rounded font-semibold hover:bg-rose-700 cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-900 focus:ring-rose-600 focus:outline-none"
|
||||
className="flex-1 px-4 py-2.5 bg-rose-600 text-white rounded font-normal hover:bg-rose-700 cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-900 focus:ring-rose-600 focus:outline-none"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
|
||||
@@ -73,8 +73,8 @@ export default function CreateUserModal({ show, onClose, onUserCreated }: Create
|
||||
<div data-testid="create-user-modal" className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-surface border border-slate-800 rounded-lg shadow-xl max-w-sm w-full mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 lg:p-8 xl:p-10 border-b border-slate-800">
|
||||
<h2 className="text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black text-white">Create User</h2>
|
||||
<div className="flex items-center justify-between p-6 border-b border-slate-800">
|
||||
<h2 className="text-lg font-normal text-white">Create User</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-muted hover:text-secondary rounded focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
||||
@@ -88,7 +88,7 @@ export default function CreateUserModal({ show, onClose, onUserCreated }: Create
|
||||
<form onSubmit={handleSubmit} className="p-6 lg:p-8 xl:p-10 space-y-4">
|
||||
{/* Username Field */}
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-semibold text-secondary mb-2">
|
||||
<label htmlFor="username" className="block text-sm font-normal text-secondary mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
@@ -113,7 +113,7 @@ export default function CreateUserModal({ show, onClose, onUserCreated }: Create
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-semibold text-secondary mb-2">
|
||||
<label htmlFor="password" className="block text-sm font-normal text-secondary mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
@@ -142,7 +142,7 @@ export default function CreateUserModal({ show, onClose, onUserCreated }: Create
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 lg:px-5 xl:px-6 py-2 lg:py-3 xl:py-4.5 text-secondary bg-slate-800 border border-slate-700 rounded font-semibold hover:bg-slate-700 transition-colors disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
||||
className="flex-1 px-4 py-2.5 text-secondary bg-slate-800 border border-slate-700 rounded font-normal hover:bg-slate-700 transition-colors disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -150,7 +150,7 @@ export default function CreateUserModal({ show, onClose, onUserCreated }: Create
|
||||
type="submit"
|
||||
data-testid="create-user-submit"
|
||||
disabled={!isFormValid || loading}
|
||||
className="flex-1 px-4 lg:px-5 xl:px-6 py-2 lg:py-3 xl:py-4.5 bg-primary text-primary-foreground rounded font-semibold hover:bg-blue-600 transition-colors disabled:opacity-50 flex items-center justify-center gap-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900 focus-visible:ring-primary focus-visible:outline-none"
|
||||
className="flex-1 px-4 py-2.5 bg-primary text-primary-foreground rounded font-normal hover:bg-blue-600 transition-colors disabled:opacity-50 flex items-center justify-center gap-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900 focus-visible:ring-primary focus-visible:outline-none"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
|
||||
393
frontend/components/DebugRotationPanel.tsx
Normal file
393
frontend/components/DebugRotationPanel.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Item } from '@/lib/db';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface DebugRotationPanelProps {
|
||||
item: Item;
|
||||
imageUrl: string;
|
||||
originalPhotoPath?: string;
|
||||
originalCropBounds?: { x: number; y: number; width: number; height: number };
|
||||
originalRotation?: number;
|
||||
imageProcessing?: any;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DebugRotationPanel({
|
||||
item,
|
||||
imageUrl,
|
||||
originalPhotoPath,
|
||||
originalCropBounds,
|
||||
originalRotation = 0,
|
||||
imageProcessing,
|
||||
onClose,
|
||||
}: DebugRotationPanelProps) {
|
||||
const [rotation, setRotation] = useState(originalRotation);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [log, setLog] = useState('');
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [scaledDimensions, setScaledDimensions] = useState({ width: 0, height: 0, scale: 1 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Adjustable crop bounds (in original image coordinates)
|
||||
const [adjustedCropBounds, setAdjustedCropBounds] = useState<{ x: number; y: number; width: number; height: number } | null>(null);
|
||||
const [draggingState, setDraggingState] = useState<{ type: string; startX: number; startY: number; startBounds: any } | null>(null);
|
||||
|
||||
// Use original photo path if available, otherwise fall back to processed image URL
|
||||
const displayImageUrl = originalPhotoPath ? originalPhotoPath : imageUrl;
|
||||
|
||||
const commonAngles = [
|
||||
{ label: '0°', value: 0 },
|
||||
{ label: '+22°', value: 22 },
|
||||
{ label: '-22°', value: -22 },
|
||||
{ label: '+45°', value: 45 },
|
||||
{ label: '-45°', value: -45 },
|
||||
{ label: '+90°', value: 90 },
|
||||
{ label: '-90°', value: -90 },
|
||||
{ label: '±180°', value: 180 },
|
||||
];
|
||||
|
||||
const updateLog = (message: string) => {
|
||||
setLog(message);
|
||||
};
|
||||
|
||||
// Get the crop bounds to display (adjusted if user modified, otherwise original)
|
||||
const displayCropBounds = adjustedCropBounds || originalCropBounds;
|
||||
|
||||
// Handle mouse down on canvas for dragging/resizing
|
||||
const handleCanvasMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!canvasRef.current || !displayCropBounds) return;
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const mouseX = (e.clientX - rect.left) / (rect.width / scaledDimensions.width);
|
||||
const mouseY = (e.clientY - rect.top) / (rect.height / scaledDimensions.height);
|
||||
|
||||
// Convert screen coords to original image coords
|
||||
const scale = scaledDimensions.scale;
|
||||
const origX = mouseX / scale;
|
||||
const origY = mouseY / scale;
|
||||
|
||||
const cropX = displayCropBounds.x;
|
||||
const cropY = displayCropBounds.y;
|
||||
const cropW = displayCropBounds.width;
|
||||
const cropH = displayCropBounds.height;
|
||||
|
||||
const margin = 15; // pixels in original coords for resize handles
|
||||
|
||||
let dragType = '';
|
||||
if (origX >= cropX - margin && origX <= cropX + margin && origY >= cropY - margin && origY <= cropY + margin) {
|
||||
dragType = 'nw'; // NW corner
|
||||
} else if (origX >= cropX + cropW - margin && origX <= cropX + cropW + margin && origY >= cropY - margin && origY <= cropY + margin) {
|
||||
dragType = 'ne'; // NE corner
|
||||
} else if (origX >= cropX - margin && origX <= cropX + margin && origY >= cropY + cropH - margin && origY <= cropY + cropH + margin) {
|
||||
dragType = 'sw'; // SW corner
|
||||
} else if (origX >= cropX + cropW - margin && origX <= cropX + cropW + margin && origY >= cropY + cropH - margin && origY <= cropY + cropH + margin) {
|
||||
dragType = 'se'; // SE corner
|
||||
} else if (origX >= cropX && origX <= cropX + cropW && origY >= cropY && origY <= cropY + cropH) {
|
||||
dragType = 'move'; // Inside the box
|
||||
}
|
||||
|
||||
if (dragType) {
|
||||
setDraggingState({
|
||||
type: dragType,
|
||||
startX: origX,
|
||||
startY: origY,
|
||||
startBounds: { ...displayCropBounds },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCanvasMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!draggingState || !canvasRef.current || !displayCropBounds) return;
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const mouseX = (e.clientX - rect.left) / (rect.width / scaledDimensions.width);
|
||||
const mouseY = (e.clientY - rect.top) / (rect.height / scaledDimensions.height);
|
||||
|
||||
const scale = scaledDimensions.scale;
|
||||
const origX = mouseX / scale;
|
||||
const origY = mouseY / scale;
|
||||
|
||||
const dx = origX - draggingState.startX;
|
||||
const dy = origY - draggingState.startY;
|
||||
|
||||
const startBounds = draggingState.startBounds;
|
||||
let newBounds = { ...startBounds };
|
||||
|
||||
if (draggingState.type === 'move') {
|
||||
newBounds.x = Math.max(0, startBounds.x + dx);
|
||||
newBounds.y = Math.max(0, startBounds.y + dy);
|
||||
} else if (draggingState.type === 'nw') {
|
||||
newBounds.x = Math.max(0, startBounds.x + dx);
|
||||
newBounds.y = Math.max(0, startBounds.y + dy);
|
||||
newBounds.width = Math.max(50, startBounds.width - dx);
|
||||
newBounds.height = Math.max(50, startBounds.height - dy);
|
||||
} else if (draggingState.type === 'ne') {
|
||||
newBounds.y = Math.max(0, startBounds.y + dy);
|
||||
newBounds.width = Math.max(50, startBounds.width + dx);
|
||||
newBounds.height = Math.max(50, startBounds.height - dy);
|
||||
} else if (draggingState.type === 'sw') {
|
||||
newBounds.x = Math.max(0, startBounds.x + dx);
|
||||
newBounds.width = Math.max(50, startBounds.width - dx);
|
||||
newBounds.height = Math.max(50, startBounds.height + dy);
|
||||
} else if (draggingState.type === 'se') {
|
||||
newBounds.width = Math.max(50, startBounds.width + dx);
|
||||
newBounds.height = Math.max(50, startBounds.height + dy);
|
||||
}
|
||||
|
||||
setAdjustedCropBounds(newBounds);
|
||||
};
|
||||
|
||||
const handleCanvasMouseUp = () => {
|
||||
setDraggingState(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
setImageLoaded(true);
|
||||
|
||||
// Calculate scaled dimensions to fit available space (much larger)
|
||||
const maxWidth = 900;
|
||||
const maxHeight = 600;
|
||||
const scale = Math.min(maxWidth / img.width, maxHeight / img.height);
|
||||
const scaledW = img.width * scale;
|
||||
const scaledH = img.height * scale;
|
||||
setScaledDimensions({ width: scaledW, height: scaledH, scale });
|
||||
updateLog(`📸 ${img.width}×${img.height}px → scaled ${scaledW.toFixed(0)}×${scaledH.toFixed(0)}px (${scale.toFixed(2)}x)`);
|
||||
};
|
||||
img.onerror = () => updateLog('❌ Failed to load image');
|
||||
img.src = displayImageUrl;
|
||||
}, [displayImageUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageLoaded || !imageRef.current || !canvasRef.current || !displayCropBounds) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const img = imageRef.current;
|
||||
const { scale } = scaledDimensions;
|
||||
|
||||
// Set canvas size to match scaled image
|
||||
canvas.width = scaledDimensions.width;
|
||||
canvas.height = scaledDimensions.height;
|
||||
|
||||
// Draw scaled image
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Calculate crop box in scaled coordinates
|
||||
const cropX = displayCropBounds.x * scale;
|
||||
const cropY = displayCropBounds.y * scale;
|
||||
const cropW = displayCropBounds.width * scale;
|
||||
const cropH = displayCropBounds.height * scale;
|
||||
|
||||
// Save state
|
||||
ctx.save();
|
||||
|
||||
// Draw orientation indicators at corners
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
|
||||
ctx.font = 'bold 14px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText('⬆ UP', 10, 5);
|
||||
ctx.fillText('⬅ LEFT', 5, 20);
|
||||
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText('RIGHT ➡', canvas.width - 10, 20);
|
||||
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText('⬇ DOWN', 10, canvas.height - 5);
|
||||
|
||||
// Draw crop bounds rectangle (bright green)
|
||||
ctx.strokeStyle = '#00FF00';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)';
|
||||
ctx.shadowBlur = 3;
|
||||
ctx.strokeRect(cropX, cropY, cropW, cropH);
|
||||
|
||||
// Draw edge orientation labels on crop box
|
||||
ctx.fillStyle = '#00FF00';
|
||||
ctx.font = 'bold 12px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)';
|
||||
ctx.shadowBlur = 2;
|
||||
|
||||
// Top edge label
|
||||
ctx.fillText('T', cropX + cropW / 2, cropY - 8);
|
||||
// Bottom edge label
|
||||
ctx.fillText('B', cropX + cropW / 2, cropY + cropH + 8);
|
||||
// Left edge label
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText('L', cropX - 8, cropY + cropH / 2);
|
||||
// Right edge label
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('R', cropX + cropW + 8, cropY + cropH / 2);
|
||||
|
||||
// Draw resize handles at corners
|
||||
const handleSize = 8;
|
||||
ctx.fillStyle = '#FFB800';
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)';
|
||||
ctx.shadowBlur = 2;
|
||||
// NW, NE, SW, SE corners
|
||||
ctx.fillRect(cropX - handleSize / 2, cropY - handleSize / 2, handleSize, handleSize);
|
||||
ctx.fillRect(cropX + cropW - handleSize / 2, cropY - handleSize / 2, handleSize, handleSize);
|
||||
ctx.fillRect(cropX - handleSize / 2, cropY + cropH - handleSize / 2, handleSize, handleSize);
|
||||
ctx.fillRect(cropX + cropW - handleSize / 2, cropY + cropH - handleSize / 2, handleSize, handleSize);
|
||||
|
||||
// Draw rotation indicator (rotated rectangle inside crop area)
|
||||
if (Math.abs(rotation) > 0.5) {
|
||||
ctx.strokeStyle = '#FFB800';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)';
|
||||
ctx.shadowBlur = 2;
|
||||
|
||||
// Draw a rotated rectangle showing where text will be after rotation
|
||||
ctx.save();
|
||||
ctx.translate(cropX + cropW / 2, cropY + cropH / 2);
|
||||
ctx.rotate((rotation * Math.PI) / 180);
|
||||
ctx.strokeRect(-cropW / 2, -cropH / 2, cropW, cropH);
|
||||
|
||||
// Add direction markers to rotated box
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = '#FFB800';
|
||||
ctx.font = 'bold 12px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('↑ TOP', 0, -cropH / 2 - 10);
|
||||
ctx.fillText('↓ BTM', 0, cropH / 2 + 10);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// Update log with current state
|
||||
let logText = `Rotation: ${rotation}°`;
|
||||
if (adjustedCropBounds) {
|
||||
logText += ` | Adjusted: (${adjustedCropBounds.x}, ${adjustedCropBounds.y}) ${adjustedCropBounds.width}×${adjustedCropBounds.height}px`;
|
||||
} else {
|
||||
logText += ` | AI Crop: (${displayCropBounds.x}, ${displayCropBounds.y}) ${displayCropBounds.width}×${displayCropBounds.height}px`;
|
||||
}
|
||||
updateLog(logText);
|
||||
}, [imageLoaded, rotation, displayCropBounds, scaledDimensions, adjustedCropBounds]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-lg shadow-2xl max-w-6xl w-full max-h-[95vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b border-slate-700 flex items-center justify-between bg-slate-800 flex-shrink-0">
|
||||
<h2 className="text-lg font-normal text-white">Debug Rotation & Crop</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-700 rounded text-gray-400 hover:text-white transition"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 p-4 flex-1 overflow-hidden">
|
||||
{/* Left: Controls */}
|
||||
<div className="w-48 bg-slate-800 p-3 rounded space-y-4 flex-shrink-0 overflow-y-auto">
|
||||
{/* Rotation Slider */}
|
||||
<div>
|
||||
<label className="block text-sm font-normal text-white mb-2">
|
||||
Rotation: <span className="text-yellow-400">{rotation}°</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="-180"
|
||||
max="180"
|
||||
value={rotation}
|
||||
onChange={(e) => setRotation(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Presets */}
|
||||
<div>
|
||||
<label className="block text-sm font-normal text-white mb-2">Quick Angles</label>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{commonAngles.map((angle) => (
|
||||
<button
|
||||
key={angle.value}
|
||||
onClick={() => setRotation(angle.value)}
|
||||
className={`py-1 px-2 rounded text-xs font-normal transition ${
|
||||
rotation === angle.value
|
||||
? 'bg-yellow-600 text-white'
|
||||
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{angle.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reset Button */}
|
||||
<button
|
||||
onClick={() => setAdjustedCropBounds(null)}
|
||||
disabled={!adjustedCropBounds}
|
||||
className="w-full py-2 px-3 rounded text-sm font-normal bg-slate-700 text-gray-300 hover:bg-slate-600 disabled:opacity-50 disabled:cursor-not-allowed transition"
|
||||
>
|
||||
Reset Crop Box
|
||||
</button>
|
||||
|
||||
{/* Original Values */}
|
||||
{originalRotation !== undefined && (
|
||||
<div className="bg-slate-700 p-3 rounded text-xs space-y-1">
|
||||
<h3 className="font-normal text-white">Original AI Values</h3>
|
||||
<div className="text-gray-300 font-mono text-xs">
|
||||
<div>Rotation: {originalRotation}°</div>
|
||||
{originalCropBounds && (
|
||||
<>
|
||||
<div>Crop ({originalCropBounds.x}, {originalCropBounds.y})</div>
|
||||
<div>Size {originalCropBounds.width}×{originalCropBounds.height}</div>
|
||||
</>
|
||||
)}
|
||||
{imageProcessing?.confidence && (
|
||||
<div>Confidence: {(imageProcessing.confidence * 100).toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Canvas + Log */}
|
||||
<div className="flex-1 flex flex-col gap-3 min-w-0 overflow-hidden">
|
||||
{/* Canvas - takes most space */}
|
||||
<div className="flex-1 flex flex-col bg-slate-800 p-3 rounded border border-slate-700 overflow-hidden">
|
||||
<h3 className="text-xs font-normal text-white mb-2">Preview (Green = Crop, Orange = Rotation)</h3>
|
||||
<div className="flex-1 flex items-center justify-center bg-black/70 rounded border border-slate-600 overflow-auto">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="rounded cursor-move"
|
||||
onMouseDown={handleCanvasMouseDown}
|
||||
onMouseMove={handleCanvasMouseMove}
|
||||
onMouseUp={handleCanvasMouseUp}
|
||||
onMouseLeave={handleCanvasMouseUp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log - single line */}
|
||||
<div className="bg-slate-800 p-2 rounded border border-slate-700 flex-shrink-0">
|
||||
<div className="bg-black text-green-400 p-2 rounded font-mono text-xs">
|
||||
{log}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,15 +60,15 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="identity-check-overlay" className="fixed inset-0 z-[100] flex items-center justify-center p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-background/90 animate-in fade-in duration-500">
|
||||
<div className="bg-surface border border-slate-800 rounded-[2.5rem] p-8 sm:p-10 max-w-sm w-full shadow-2xl space-y-8 animate-in zoom-in-95 duration-300 relative overflow-hidden">
|
||||
<div data-testid="identity-check-overlay" className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-background/90 animate-in fade-in duration-500">
|
||||
<div className="bg-surface border border-slate-800 rounded-[2.5rem] p-4 md:p-6 max-w-sm w-full shadow-2xl space-y-3 md:space-y-4 animate-in zoom-in-95 duration-300 relative overflow-hidden">
|
||||
|
||||
<div className="text-center space-y-3">
|
||||
<div className="w-20 h-20 bg-primary/10 text-primary rounded-[2rem] flex items-center justify-center mx-auto mb-6 border border-primary/20 shadow-xl shadow-primary/5">
|
||||
<Shield size={40} className="italic" />
|
||||
</div>
|
||||
<h2 className="text-3xl lg:text-4xl xl:text-5xl font-black text-white tracking-tight">Protocol Access</h2>
|
||||
<p className="text-muted text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black">Select operator profile to initialize</p>
|
||||
<h2 className="text-3xl font-normal text-white tracking-tight">Protocol Access</h2>
|
||||
<p className="text-muted text-xs font-normal">Select operator profile to initialize</p>
|
||||
</div>
|
||||
|
||||
<div data-testid="user-list" className="grid gap-3.5">
|
||||
@@ -86,8 +86,8 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
||||
{user.role === 'admin' ? <Shield size={18} /> : <User size={18} />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-secondary font-black text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl tracking-tight">{user.username}</p>
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-secondary font-black mt-0.5">{user.role}</p>
|
||||
<p className="text-secondary font-normal text-sm tracking-tight">{user.username}</p>
|
||||
<p className="text-xs text-secondary font-normal mt-0.5">{user.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-1.5 rounded-full bg-surface/70 text-muted group-hover:text-primary group-hover:bg-primary/10 transition-all">
|
||||
@@ -98,7 +98,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={() => setIsEnterprise(true)}
|
||||
className="w-full flex items-center justify-center gap-3 py-5 rounded-[1.5rem] border border-dashed border-slate-800 text-secondary hover:text-primary hover:border-primary/40 hover:bg-primary/5 transition-all font-black text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl"
|
||||
className="w-full flex items-center justify-center gap-3 py-5 rounded-[1.5rem] border border-dashed border-slate-800 text-secondary hover:text-primary hover:border-primary/40 hover:bg-primary/5 transition-all font-normal text-xs"
|
||||
>
|
||||
<Lock size={14} />
|
||||
Enterprise Directory
|
||||
@@ -108,11 +108,11 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
||||
) : isEnterprise ? (
|
||||
<div className="space-y-5 animate-in slide-in-from-bottom-5 duration-300">
|
||||
<div className="flex justify-between items-center px-1">
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-secondary italic">LDAP Authentication</p>
|
||||
<p className="text-xs font-normal text-secondary italic">LDAP Authentication</p>
|
||||
<button
|
||||
data-testid="local-login-tab"
|
||||
onClick={() => setIsEnterprise(false)}
|
||||
className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-primary hover:underline tracking-tighter"
|
||||
className="text-xs font-normal text-primary hover:underline tracking-tighter"
|
||||
>
|
||||
Profile Swap
|
||||
</button>
|
||||
@@ -149,7 +149,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
||||
<button
|
||||
data-testid="login-submit"
|
||||
onClick={handleLogin}
|
||||
className="w-full bg-primary text-white font-black py-5 rounded-[1.5rem] shadow-2xl shadow-primary/20 hover:shadow-primary/30 active:scale-95 transition-all text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl border border-primary/20"
|
||||
className="w-full bg-primary text-white font-normal py-5 rounded-[1.5rem] shadow-2xl shadow-primary/20 hover:shadow-primary/30 active:scale-95 transition-all text-xs border border-primary/20"
|
||||
>
|
||||
Initialize Session
|
||||
</button>
|
||||
@@ -162,8 +162,8 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
||||
<Shield size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-secondary">Active Profile</p>
|
||||
<p className="text-white font-black tracking-tight">{selectedUserForLogin.username}</p>
|
||||
<p className="text-xs font-normal text-secondary">Active Profile</p>
|
||||
<p className="text-white font-normal tracking-tight">{selectedUserForLogin.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -193,7 +193,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
||||
<button
|
||||
data-testid="local-login-submit"
|
||||
onClick={handleLogin}
|
||||
className="w-full bg-primary text-white font-black py-5 rounded-[1.5rem] shadow-2xl shadow-primary/20 hover:shadow-primary/30 active:scale-95 transition-all text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl border border-primary/20"
|
||||
className="w-full bg-primary text-white font-normal py-5 rounded-[1.5rem] shadow-2xl shadow-primary/20 hover:shadow-primary/30 active:scale-95 transition-all text-xs border border-primary/20"
|
||||
>
|
||||
Unlock Account
|
||||
</button>
|
||||
@@ -202,7 +202,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
||||
</div>
|
||||
|
||||
{users.length === 0 && (
|
||||
<div className="text-center p-8 text-muted animate-pulse font-black text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl">
|
||||
<div className="text-center p-8 text-muted animate-pulse font-normal text-xs">
|
||||
Synchronizing User Tokens...
|
||||
</div>
|
||||
)}
|
||||
|
||||
325
frontend/components/ImageAdjustmentModal.tsx
Normal file
325
frontend/components/ImageAdjustmentModal.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { X, RotateCw } from 'lucide-react';
|
||||
|
||||
interface ImageAdjustmentModalProps {
|
||||
imageUrl: string;
|
||||
onConfirm: (adjustedImage: { rotation: number; cropBounds: { x: number; y: number; width: number; height: number }; imageBlob?: Blob } | null) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ImageAdjustmentModal({ imageUrl, onConfirm, onCancel }: ImageAdjustmentModalProps) {
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [minZoom, setMinZoom] = useState(0.1);
|
||||
const [maxZoom, setMaxZoom] = useState(5);
|
||||
const [panX, setPanX] = useState(0);
|
||||
const [panY, setPanY] = useState(0);
|
||||
const [useImage, setUseImage] = useState(true);
|
||||
const [aspectRatio, setAspectRatio] = useState<number | null>(null);
|
||||
const [cropBounds, setCropBounds] = useState<{ x: number; y: number; width: number; height: number } | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 });
|
||||
const [isDraggingCrop, setIsDraggingCrop] = useState<{ type: string; startX: number; startY: number } | null>(null);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [lastTouchDistance, setLastTouchDistance] = useState(0);
|
||||
const [lastTouchX, setLastTouchX] = useState(0);
|
||||
const [lastTouchY, setLastTouchY] = useState(0);
|
||||
|
||||
const aspectRatios = [
|
||||
{ label: 'Free', value: null },
|
||||
{ label: '16:9', value: 16 / 9 },
|
||||
{ label: '4:3', value: 4 / 3 },
|
||||
{ label: '1:1', value: 1 },
|
||||
{ label: '9:16', value: 9 / 16 },
|
||||
];
|
||||
|
||||
// Load image
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
setImageLoaded(true);
|
||||
setImageDimensions({ width: img.width, height: img.height });
|
||||
// Initialize crop bounds to full image
|
||||
setCropBounds({ x: 0, y: 0, width: img.width, height: img.height });
|
||||
|
||||
// Calculate initial zoom to fit image in canvas (800x600)
|
||||
const canvasWidth = 800;
|
||||
const canvasHeight = 600;
|
||||
const zoomX = canvasWidth / img.width;
|
||||
const zoomY = canvasHeight / img.height;
|
||||
const fitZoom = Math.min(zoomX, zoomY); // Allow any zoom needed to fit
|
||||
setMinZoom(fitZoom); // Min zoom shows entire image
|
||||
setZoom(fitZoom); // Start at fit level
|
||||
};
|
||||
img.src = imageUrl;
|
||||
}, [imageUrl]);
|
||||
|
||||
// Draw canvas with image, rotation, crop box
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !imageRef.current || !imageLoaded || !cropBounds) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Canvas size
|
||||
canvas.width = 800;
|
||||
canvas.height = 600;
|
||||
|
||||
ctx.fillStyle = '#1e1e1e';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Save state for rotation
|
||||
ctx.save();
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||
|
||||
// Apply zoom
|
||||
ctx.scale(zoom, zoom);
|
||||
|
||||
// Apply pan
|
||||
ctx.translate(panX / zoom, panY / zoom);
|
||||
|
||||
// Apply rotation
|
||||
ctx.rotate((rotation * Math.PI) / 180);
|
||||
|
||||
// Draw image centered
|
||||
ctx.drawImage(imageRef.current, -imageDimensions.width / 2, -imageDimensions.height / 2);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// Draw crop box (in canvas coordinates, accounting for transformations)
|
||||
// This is simplified - for full accuracy, we'd need to transform crop bounds
|
||||
ctx.strokeStyle = '#00FF00';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.strokeRect(50, 50, 300, 200); // Placeholder - will improve
|
||||
|
||||
}, [imageLoaded, rotation, zoom, panX, panY, cropBounds]);
|
||||
|
||||
const handleReset = () => {
|
||||
setRotation(0);
|
||||
setPanX(0);
|
||||
setPanY(0);
|
||||
if (imageRef.current) {
|
||||
setCropBounds({ x: 0, y: 0, width: imageRef.current.width, height: imageRef.current.height });
|
||||
// Reset to initial fit zoom
|
||||
const canvasWidth = 800;
|
||||
const canvasHeight = 600;
|
||||
const zoomX = canvasWidth / imageRef.current.width;
|
||||
const zoomY = canvasHeight / imageRef.current.height;
|
||||
const fitZoom = Math.min(zoomX, zoomY, 1);
|
||||
setZoom(fitZoom);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!useImage || !imageRef.current) {
|
||||
console.log('[Modal] User clicked confirm - no image/useImage flag');
|
||||
onConfirm(null);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== IMAGE ADJUSTMENT MODAL DEBUG ===');
|
||||
console.log('[Original] Image dimensions:', imageDimensions);
|
||||
console.log('[User] Rotation:', rotation, '°');
|
||||
console.log('[User] Use image flag:', useImage);
|
||||
|
||||
// DON'T process image in frontend - let backend handle rotation
|
||||
// Just send the rotation value, backend will apply it once
|
||||
const { width, height } = imageDimensions;
|
||||
|
||||
console.log('[Decision] Sending rotation to backend for processing');
|
||||
console.log('[Decision] NOT processing image in frontend (avoid double rotation)');
|
||||
console.log('=== END DEBUG ===');
|
||||
|
||||
// Return rotation value ONLY - no imageBlob
|
||||
// Backend will apply rotation to original image
|
||||
onConfirm({
|
||||
rotation,
|
||||
cropBounds: { x: 0, y: 0, width, height }
|
||||
// NO imageBlob - use original from extractedImageBlob
|
||||
});
|
||||
};
|
||||
|
||||
const handleWheel = (e: React.WheelEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
setZoom(prev => Math.max(minZoom, Math.min(maxZoom, prev * delta)));
|
||||
};
|
||||
|
||||
const getTouchDistance = (touches: React.TouchList) => {
|
||||
if (touches.length < 2) return 0;
|
||||
const dx = touches[0].clientX - touches[1].clientX;
|
||||
const dy = touches[0].clientY - touches[1].clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
if (e.touches.length === 2) {
|
||||
setLastTouchDistance(getTouchDistance(e.touches));
|
||||
} else if (e.touches.length === 1) {
|
||||
setIsPanning(true);
|
||||
setLastTouchX(e.touches[0].clientX);
|
||||
setLastTouchY(e.touches[0].clientY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.touches.length === 2) {
|
||||
// Pinch zoom
|
||||
const newDistance = getTouchDistance(e.touches);
|
||||
if (lastTouchDistance > 0) {
|
||||
const delta = newDistance / lastTouchDistance;
|
||||
setZoom(prev => Math.max(minZoom, Math.min(maxZoom, prev * delta)));
|
||||
setLastTouchDistance(newDistance);
|
||||
}
|
||||
} else if (e.touches.length === 1 && isPanning) {
|
||||
// Single finger pan
|
||||
const touch = e.touches[0];
|
||||
const dx = touch.clientX - lastTouchX;
|
||||
const dy = touch.clientY - lastTouchY;
|
||||
setPanX(prev => prev + dx);
|
||||
setPanY(prev => prev + dy);
|
||||
setLastTouchX(touch.clientX);
|
||||
setLastTouchY(touch.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setIsPanning(false);
|
||||
setLastTouchDistance(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-lg shadow-2xl max-w-6xl w-full max-h-[95vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-slate-700 flex items-center justify-between bg-slate-800">
|
||||
<h2 className="text-xl font-normal text-white">Rotate Image</h2>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-2 hover:bg-slate-700 rounded text-gray-400 hover:text-white transition"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 p-4 flex-1 overflow-hidden">
|
||||
{/* Left: Controls */}
|
||||
<div className="w-56 bg-slate-800 p-4 rounded space-y-4 flex-shrink-0 overflow-y-auto">
|
||||
{/* Rotation */}
|
||||
<div>
|
||||
<label className="block text-sm font-normal text-white mb-2">
|
||||
Rotation: <span className="text-yellow-400">{rotation}°</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="-180"
|
||||
max="180"
|
||||
value={rotation}
|
||||
onChange={(e) => setRotation(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zoom */}
|
||||
<div>
|
||||
<label className="block text-sm font-normal text-white mb-2">
|
||||
Zoom: <span className="text-yellow-400">{zoom.toFixed(2)}x</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={minZoom}
|
||||
max={maxZoom}
|
||||
step="0.1"
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Aspect Ratio - DISABLED (cropping not implemented) */}
|
||||
{/* <div>
|
||||
<label className="block text-sm font-normal text-white mb-2">Aspect Ratio</label>
|
||||
<div className="space-y-2">
|
||||
{aspectRatios.map((ratio) => (
|
||||
<button
|
||||
key={ratio.label}
|
||||
onClick={() => setAspectRatio(ratio.value)}
|
||||
className={`w-full py-2 px-3 rounded text-sm font-normal transition ${
|
||||
aspectRatio === ratio.value
|
||||
? 'bg-yellow-600 text-white'
|
||||
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{ratio.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* Reset */}
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="w-full py-2 px-3 rounded text-sm font-normal bg-slate-700 text-gray-300 hover:bg-slate-600 transition flex items-center justify-center gap-2"
|
||||
>
|
||||
<RotateCw size={16} />
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Center: Canvas */}
|
||||
<div className="flex-1 flex flex-col bg-slate-800 p-4 rounded border border-slate-700 overflow-hidden">
|
||||
<h3 className="text-sm font-normal text-white mb-2">Image Preview</h3>
|
||||
<div className="flex-1 flex items-center justify-center bg-black/50 rounded border border-slate-600 overflow-hidden">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onWheel={handleWheel}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
className="max-w-full max-h-full cursor-grab active:cursor-grabbing touch-none"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2">Scroll to zoom | Drag to pan | Rotate with slider</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-slate-700 bg-slate-800 flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-sm font-normal text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useImage}
|
||||
onChange={(e) => setUseImage(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
Use this image
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded text-sm font-normal bg-slate-700 text-gray-300 hover:bg-slate-600 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="px-4 py-2 rounded text-sm font-normal bg-yellow-600 text-white hover:bg-yellow-700 transition"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Item } from '@/lib/db';
|
||||
import { buildPhotoUrl } from '@/lib/api';
|
||||
import { ChevronRight, ChevronDown, Layers, Package } from 'lucide-react';
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import ItemDetailModal from '@/components/ItemDetailModal';
|
||||
import PhotoModal from '@/components/PhotoModal';
|
||||
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -18,6 +21,7 @@ interface InventoryTableProps {
|
||||
onItemClick: (item: Item) => void;
|
||||
onEditCategory?: (category: string) => void;
|
||||
categoriesList?: any[];
|
||||
backendUrl?: string;
|
||||
}
|
||||
|
||||
export default function InventoryTable({
|
||||
@@ -27,8 +31,26 @@ export default function InventoryTable({
|
||||
onExpandCategory,
|
||||
onItemClick,
|
||||
onEditCategory,
|
||||
categoriesList = []
|
||||
categoriesList = [],
|
||||
backendUrl = ''
|
||||
}: InventoryTableProps) {
|
||||
const [selectedItemDetail, setSelectedItemDetail] = useState<Item | null>(null);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
const [selectedPhotoItem, setSelectedPhotoItem] = useState<Item | null>(null);
|
||||
|
||||
const handleItemClick = (item: Item) => {
|
||||
setSelectedItemDetail(item);
|
||||
onItemClick(item);
|
||||
};
|
||||
|
||||
const handleCloseDetail = () => {
|
||||
setSelectedItemDetail(null);
|
||||
};
|
||||
|
||||
const handleItemRefresh = () => {
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
};
|
||||
|
||||
if (categories.length === 0) {
|
||||
return (
|
||||
<div className="py-2 lg:py-3 xl:py-40 text-center text-secondary">
|
||||
@@ -81,26 +103,52 @@ export default function InventoryTable({
|
||||
{categoryItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => onItemClick(item)}
|
||||
className="bg-background/40 border border-slate-800/50 p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl flex items-center justify-between hover:border-primary/40 cursor-pointer transition-all active:scale-[0.98]"
|
||||
className="bg-background/40 border border-slate-800/50 p-4 rounded-2xl flex items-center justify-between hover:border-primary/40 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0 pr-4">
|
||||
<div className="w-8 h-8 rounded-xl bg-green-500/10 flex items-center justify-center text-green-500 shrink-0">
|
||||
<Package size={14} />
|
||||
</div>
|
||||
<div
|
||||
onClick={() => handleItemClick(item)}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 pr-4 cursor-pointer"
|
||||
>
|
||||
{item.photo_path || item.image_url ? (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedPhotoItem(item);
|
||||
}}
|
||||
className="w-12 h-12 rounded-xl shrink-0 border-2 border-slate-300 overflow-hidden cursor-pointer hover:border-primary transition-colors active:scale-95"
|
||||
>
|
||||
<img
|
||||
src={buildPhotoUrl(backendUrl, item.photo_path || item.image_url || '')}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-xl bg-green-500/10 flex items-center justify-center text-green-500 shrink-0">
|
||||
<Package size={14} />
|
||||
</div>
|
||||
)}
|
||||
<div className="truncate">
|
||||
<h4 className="card-title truncate">{item.name}</h4>
|
||||
<p className="card-subtitle mt-0 lowercase opacity-80 truncate">{item.specs}</p>
|
||||
{item.photo_path || item.image_url ? (
|
||||
<p className="card-subtitle mt-0 lowercase opacity-80 text-xs">Tap photo for details</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="card-subtitle mt-0 lowercase opacity-80 truncate">{item.specs}</p>
|
||||
<p className="card-subtitle mt-0 text-xs">No photo</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span className={cn(
|
||||
"text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black",
|
||||
"text-lg font-normal",
|
||||
item.quantity <= item.min_quantity ? "text-amber-500" : "text-primary"
|
||||
)}>
|
||||
{item.quantity}
|
||||
</span>
|
||||
<p className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-muted font-bold tracking-tight">Stock</p>
|
||||
<p className="text-sm text-muted font-normal tracking-tight">Stock</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -109,6 +157,23 @@ export default function InventoryTable({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedItemDetail && (
|
||||
<ItemDetailModal
|
||||
item={selectedItemDetail}
|
||||
onClose={handleCloseDetail}
|
||||
onItemRefresh={handleItemRefresh}
|
||||
backendUrl={backendUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedPhotoItem && (selectedPhotoItem.photo_path || selectedPhotoItem.image_url) && (
|
||||
<PhotoModal
|
||||
photoUrl={selectedPhotoItem.photo_path || selectedPhotoItem.image_url || ''}
|
||||
title={selectedPhotoItem.name}
|
||||
onClose={() => setSelectedPhotoItem(null)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,17 +47,17 @@ export default function ItemComparisonModal({
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<AlertTriangle size={24} className="text-amber-500" />
|
||||
<div>
|
||||
<h3 className="text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-white">Part Number Already Exists</h3>
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-secondary mt-1">Compare the versions below</p>
|
||||
<h3 className="text-xl font-normal text-white">Part Number Already Exists</h3>
|
||||
<p className="text-xs text-secondary mt-1">Compare the versions below</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comparison Table */}
|
||||
<div className="space-y-3 mb-8">
|
||||
<div className="grid grid-cols-3 gap-4 mb-4 pb-4 border-b border-slate-800">
|
||||
<div className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-muted">Field</div>
|
||||
<div className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-secondary">Current (ID: {existingItem?.id})</div>
|
||||
<div className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-primary">New (Import)</div>
|
||||
<div className="text-xs font-normal text-muted">Field</div>
|
||||
<div className="text-xs font-normal text-secondary">Current (ID: {existingItem?.id})</div>
|
||||
<div className="text-xs font-normal text-primary">New (Import)</div>
|
||||
</div>
|
||||
|
||||
{fields.map(field => {
|
||||
@@ -72,11 +72,11 @@ export default function ItemComparisonModal({
|
||||
different ? 'bg-amber-500/10 border border-amber-500/20' : 'bg-slate-800/30'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary">{field.label}</div>
|
||||
<div className={`text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-mono ${different ? 'text-amber-200' : 'text-secondary'}`}>
|
||||
<div className="text-sm font-normal text-secondary">{field.label}</div>
|
||||
<div className={`text-sm font-mono ${different ? 'text-amber-200' : 'text-secondary'}`}>
|
||||
{existing}
|
||||
</div>
|
||||
<div className={`text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-mono ${different ? 'text-primary font-bold' : 'text-secondary'}`}>
|
||||
<div className={`text-sm font-mono ${different ? 'text-primary font-normal' : 'text-secondary'}`}>
|
||||
{newVal}
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,7 +96,7 @@ export default function ItemComparisonModal({
|
||||
onClick={onSkip}
|
||||
disabled={loading}
|
||||
aria-label="Skip this item comparison"
|
||||
className="flex-1 flex items-center justify-center gap-2 py-4 bg-slate-800 hover:bg-slate-700 text-secondary rounded-2xl text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black cursor-pointer transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed border border-slate-700 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
className="flex-1 flex items-center justify-center gap-2 py-4 bg-slate-800 hover:bg-slate-700 text-secondary rounded-2xl text-sm font-normal cursor-pointer transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed border border-slate-700 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<SkipForward size={16} /> Skip
|
||||
</button>
|
||||
@@ -105,7 +105,7 @@ export default function ItemComparisonModal({
|
||||
onClick={onUpdate}
|
||||
disabled={loading}
|
||||
aria-label="Update item with new data"
|
||||
className="flex-1 flex items-center justify-center gap-2 py-4 bg-primary hover:bg-blue-600 text-white rounded-2xl text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black cursor-pointer transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed shadow-xl shadow-primary/20 focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||
className="flex-1 flex items-center justify-center gap-2 py-4 bg-primary hover:bg-blue-600 text-white rounded-2xl text-sm font-normal cursor-pointer transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed shadow-xl shadow-primary/20 focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
|
||||
222
frontend/components/ItemDetailModal.tsx
Normal file
222
frontend/components/ItemDetailModal.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Item } from '@/lib/db';
|
||||
import { inventoryApi, buildPhotoUrl } from '@/lib/api';
|
||||
import ItemPhotoUpload from '@/components/ItemPhotoUpload';
|
||||
import { DebugRotationPanel } from '@/components/DebugRotationPanel';
|
||||
import { X, Camera, Trash2, Wrench } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface ItemDetailModalProps {
|
||||
item: Item;
|
||||
onClose: () => void;
|
||||
onPhotoUpdated?: (photo: { thumbnail_url: string; full_url: string; uploaded_at: string }) => void;
|
||||
onItemRefresh?: () => void;
|
||||
backendUrl?: string;
|
||||
}
|
||||
|
||||
export default function ItemDetailModal({
|
||||
item,
|
||||
onClose,
|
||||
onPhotoUpdated,
|
||||
onItemRefresh,
|
||||
backendUrl = '',
|
||||
}: ItemDetailModalProps) {
|
||||
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
||||
const [showDebugPanel, setShowDebugPanel] = useState(false);
|
||||
const [currentPhoto, setCurrentPhoto] = useState<{ thumbnail_url: string; full_url: string } | null>(
|
||||
item.photo_path && item.photo_thumbnail_path
|
||||
? { thumbnail_url: item.photo_thumbnail_path, full_url: item.photo_path }
|
||||
: item.image_url
|
||||
? { thumbnail_url: item.image_url, full_url: item.image_url }
|
||||
: null
|
||||
);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const photoUploadRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handlePhotoUploadSuccess = (photo: { thumbnail_url: string; full_url: string; uploaded_at: string }) => {
|
||||
setCurrentPhoto({ thumbnail_url: photo.thumbnail_url, full_url: photo.full_url });
|
||||
setShowPhotoUpload(false);
|
||||
onPhotoUpdated?.(photo);
|
||||
onItemRefresh?.();
|
||||
};
|
||||
|
||||
const handlePhotoUploadError = (errorMessage: string) => {
|
||||
toast.error(errorMessage);
|
||||
};
|
||||
|
||||
const handleDeletePhoto = async () => {
|
||||
if (!item.id) return;
|
||||
|
||||
if (!window.confirm('Delete this photo? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await inventoryApi.deleteItemPhoto(item.id);
|
||||
setCurrentPhoto(null);
|
||||
toast.success('Photo deleted successfully');
|
||||
onItemRefresh?.();
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message || 'Failed to delete photo';
|
||||
toast.error(errorMsg);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-surface border border-slate-800 rounded-3xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-surface border-b border-slate-800/50 p-4 md:p-6 flex items-center justify-between">
|
||||
<h2 className="text-xl md:text-2xl font-normal text-white truncate">{item.name}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentPhoto && (
|
||||
<button
|
||||
onClick={() => setShowDebugPanel(true)}
|
||||
className="p-2 hover:bg-yellow-900/50 rounded-full text-yellow-400 hover:text-yellow-300 transition-colors"
|
||||
title="Debug rotation & crop"
|
||||
aria-label="Debug panel"
|
||||
>
|
||||
<Wrench size={20} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-800 rounded-full text-muted hover:text-white transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* Item Details */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted mb-1">Category</p>
|
||||
<p className="text-sm text-white">{item.category}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted mb-1">Type</p>
|
||||
<p className="text-sm text-white">{item.type || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted mb-1">Quantity</p>
|
||||
<p className="text-sm text-white">{item.quantity}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted mb-1">Part Number</p>
|
||||
<p className="text-sm text-white">{item.part_number || 'N/A'}</p>
|
||||
</div>
|
||||
{item.barcode && (
|
||||
<div className="col-span-2">
|
||||
<p className="text-xs text-muted mb-1">Barcode</p>
|
||||
<p className="text-sm text-white font-mono">{item.barcode}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Photo Section */}
|
||||
<div className="border-t border-slate-800/50 pt-6">
|
||||
<h3 className="text-lg font-normal text-white mb-4 flex items-center gap-2">
|
||||
<Camera size={18} className="text-primary" />
|
||||
Photo
|
||||
</h3>
|
||||
|
||||
{!showPhotoUpload ? (
|
||||
<>
|
||||
{currentPhoto ? (
|
||||
<div className="space-y-3">
|
||||
<div className="relative bg-slate-900/50 rounded-2xl overflow-hidden border border-slate-800/50 aspect-video max-h-96">
|
||||
<img
|
||||
src={buildPhotoUrl(backendUrl, currentPhoto.full_url)}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowPhotoUpload(true)}
|
||||
className="flex-1 px-4 py-2 bg-primary/20 border border-primary/50 text-primary rounded-xl hover:bg-primary/30 transition-colors font-normal text-sm"
|
||||
>
|
||||
Replace Photo
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeletePhoto}
|
||||
disabled={isDeleting}
|
||||
className="px-4 py-2 bg-rose-500/20 border border-rose-500/50 text-rose-400 rounded-xl hover:bg-rose-500/30 disabled:opacity-50 transition-colors font-normal text-sm"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-slate-900/30 border border-slate-800/50 rounded-2xl p-8 text-center">
|
||||
<p className="text-muted text-sm mb-4">No photo uploaded</p>
|
||||
<button
|
||||
onClick={() => setShowPhotoUpload(true)}
|
||||
className="px-4 py-2 bg-primary/20 border border-primary/50 text-primary rounded-xl hover:bg-primary/30 transition-colors font-normal text-sm"
|
||||
>
|
||||
Upload Photo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div ref={photoUploadRef} className="bg-slate-900/30 border border-slate-800/50 rounded-2xl p-4 md:p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h4 className="font-normal text-white">Upload New Photo</h4>
|
||||
<button
|
||||
onClick={() => setShowPhotoUpload(false)}
|
||||
className="p-1 hover:bg-slate-800 rounded-lg text-muted hover:text-white transition-colors"
|
||||
aria-label="Cancel upload"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{item.id && (
|
||||
<ItemPhotoUpload
|
||||
itemId={item.id}
|
||||
onUploadSuccess={handlePhotoUploadSuccess}
|
||||
onError={handlePhotoUploadError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Debug Rotation Panel */}
|
||||
{showDebugPanel && currentPhoto && (
|
||||
<DebugRotationPanel
|
||||
item={item}
|
||||
imageUrl={buildPhotoUrl(backendUrl, currentPhoto.full_url)}
|
||||
originalPhotoPath={
|
||||
item.labels_data
|
||||
? (() => {
|
||||
try {
|
||||
const parsed = JSON.parse(item.labels_data);
|
||||
const origPath = parsed.image_processing?.original_photo_path;
|
||||
return origPath ? buildPhotoUrl(backendUrl, origPath) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})()
|
||||
: undefined
|
||||
}
|
||||
originalCropBounds={item.labels_data ? JSON.parse(item.labels_data).image_processing?.crop_bounds : undefined}
|
||||
originalRotation={item.labels_data ? JSON.parse(item.labels_data).image_processing?.rotation_degrees : undefined}
|
||||
imageProcessing={item.labels_data ? JSON.parse(item.labels_data).image_processing : undefined}
|
||||
onClose={() => setShowDebugPanel(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
frontend/components/ItemPhotoUpload.tsx
Normal file
155
frontend/components/ItemPhotoUpload.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Camera, Upload, Loader2 } from 'lucide-react';
|
||||
import { usePhotoUpload } from '@/hooks/usePhotoUpload';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface ItemPhotoUploadProps {
|
||||
itemId: number;
|
||||
onUploadSuccess: (photo: { thumbnail_url: string; full_url: string; uploaded_at: string }) => void;
|
||||
onError: (errorMessage: string) => void;
|
||||
}
|
||||
|
||||
export default function ItemPhotoUpload({
|
||||
itemId,
|
||||
onUploadSuccess,
|
||||
onError,
|
||||
}: ItemPhotoUploadProps) {
|
||||
const { upload, isLoading, error } = usePhotoUpload();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const toastIdRef = useRef<string | null>(null);
|
||||
|
||||
// Sync hook error to local state for display
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
setLocalError(error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
// Cleanup: dismiss pending toasts on unmount
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (toastIdRef.current) {
|
||||
toast.dismiss(toastIdRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = async (file: File) => {
|
||||
setLocalError(null);
|
||||
|
||||
if (!file) return;
|
||||
|
||||
toastIdRef.current = toast.loading('Uploading...');
|
||||
|
||||
try {
|
||||
const photo = await upload(file, itemId);
|
||||
toast.success('Photo uploaded successfully', { id: toastIdRef.current });
|
||||
toastIdRef.current = null;
|
||||
onUploadSuccess(photo);
|
||||
|
||||
// Reset file inputs
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
if (cameraInputRef.current) {
|
||||
cameraInputRef.current.value = '';
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.message || 'Upload failed';
|
||||
setLocalError(errorMsg);
|
||||
toast.error(errorMsg, { id: toastIdRef.current || undefined });
|
||||
toastIdRef.current = null;
|
||||
onError(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFileSelect(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCameraCapture = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFileSelect(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const triggerCameraInput = () => {
|
||||
cameraInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Hidden file inputs */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileInputChange}
|
||||
className="sr-only"
|
||||
aria-label="Upload photo from device"
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={handleCameraCapture}
|
||||
className="sr-only"
|
||||
aria-label="Capture photo with camera"
|
||||
/>
|
||||
|
||||
{/* Button group */}
|
||||
<div className="flex gap-2">
|
||||
{/* File upload button */}
|
||||
<button
|
||||
onClick={triggerFileInput}
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2 flex-1 px-4 py-2.5 bg-primary text-white rounded-lg font-normal text-base transition-colors hover:bg-primary/90 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
aria-label="Upload photo"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-5 h-5" />
|
||||
)}
|
||||
<span>Upload</span>
|
||||
</button>
|
||||
|
||||
{/* Camera button (mobile) */}
|
||||
<button
|
||||
onClick={triggerCameraInput}
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2 flex-1 px-4 py-2.5 bg-slate-700 text-white rounded-lg font-normal text-base transition-colors hover:bg-slate-600 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
aria-label="Capture photo with camera"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Camera className="w-5 h-5" />
|
||||
)}
|
||||
<span>Camera</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status messages */}
|
||||
{isLoading && (
|
||||
<div className="text-sm text-slate-400">Uploading...</div>
|
||||
)}
|
||||
|
||||
{localError && (
|
||||
<div className="text-sm text-rose-500">{localError}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,8 +17,8 @@ export default function LogsOverlay({ show, onClose, logs, inventory }: LogsOver
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-background p-6 lg:p-8 xl:p-10 animate-in slide-in-from-bottom-20 duration-500">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl lg:text-3xl xl:text-4xl font-black tracking-tight">Audit History</h2>
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted">Live transaction log from cloud</p>
|
||||
<h2 className="text-2xl font-normal tracking-tight">Audit History</h2>
|
||||
<p className="text-xs text-muted">Live transaction log from cloud</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -39,16 +39,16 @@ export default function LogsOverlay({ show, onClose, logs, inventory }: LogsOver
|
||||
<div key={log.id} className="bg-surface/70 border border-slate-800 p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className={`text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black ${
|
||||
<p className={`text-xs font-normal ${
|
||||
log.action.includes('CHECK_IN') ? "text-green-500" : (log.action.includes('TRASH') ? "text-rose-500" : "text-amber-500")
|
||||
}`}>
|
||||
{log.action}
|
||||
</p>
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-muted">by</span>
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-secondary">{log.username || 'System'}</span>
|
||||
<span className="text-xs font-normal text-muted">by</span>
|
||||
<span className="text-xs font-normal text-secondary">{log.username || 'System'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary">
|
||||
<span className="text-sm font-normal text-secondary">
|
||||
{inventory.find(i => i.id === log.target_item_id)?.name || `Item #${log.target_item_id}`}
|
||||
</span>
|
||||
</div>
|
||||
@@ -66,7 +66,7 @@ export default function LogsOverlay({ show, onClose, logs, inventory }: LogsOver
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black ${
|
||||
<p className={`text-lg font-normal ${
|
||||
log.quantity_change > 0 ? "text-green-400" : "text-rose-400"
|
||||
}`}>
|
||||
{log.quantity_change > 0 ? '+' : ''}{log.quantity_change}
|
||||
|
||||
@@ -27,20 +27,20 @@ export default function LogsTable({ logs, loading }: LogsTableProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-3 lg:py-4 xl:py-52 text-secondary gap-4 animate-pulse">
|
||||
<div className="w-10 h-10 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black tracking-widest italic">Securing Audit Stream...</p>
|
||||
<p className="text-xs font-normal tracking-widest italic">Securing Audit Stream...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
return (
|
||||
<div className="bg-surface/20 border border-slate-800/50 border-dashed rounded-[2.5rem] py-2 lg:py-3 xl:py-40 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div className="bg-surface/20 border border-slate-800/50 border-dashed rounded-[2.5rem] py-20 flex flex-col items-center justify-center text-center gap-3 md:gap-4">
|
||||
<div className="w-16 h-16 bg-surface rounded-2xl flex items-center justify-center text-slate-700 border border-slate-800">
|
||||
<ArrowDownCircle size={32} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-secondary tracking-tight">No events found</p>
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-secondary font-bold mt-1">Refine your strategic filters</p>
|
||||
<p className="text-xl font-normal text-secondary tracking-tight">No events found</p>
|
||||
<p className="text-xs text-secondary font-normal mt-1">Refine your strategic filters</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -58,7 +58,7 @@ export default function LogsTable({ logs, loading }: LogsTableProps) {
|
||||
<div className="flex-1 min-w-0 z-10 flex items-center gap-4">
|
||||
{/* Compact Action Badge */}
|
||||
<div className={cn(
|
||||
"text-[10px] font-black px-3 lg:px-4 xl:px-5 py-1.5 rounded-lg border min-w-[85px] text-center tracking-tight",
|
||||
"text-[10px] font-normal px-3 py-1.5 rounded-lg border min-w-[85px] text-center tracking-tight",
|
||||
log.action.includes('CHECK_IN') ? "bg-green-500/10 text-green-500 border-green-500/20" :
|
||||
(log.action.includes('TRASH') ? "bg-rose-500/10 text-rose-500 border-rose-500/20" :
|
||||
(log.action.includes('DB') ? "bg-sky-500/10 text-sky-400 border-sky-500/20" :
|
||||
@@ -84,7 +84,7 @@ export default function LogsTable({ logs, loading }: LogsTableProps) {
|
||||
|
||||
<div className="shrink-0 flex items-center gap-3 z-10">
|
||||
<div className={cn(
|
||||
"text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black tabular-nums min-w-[35px] text-right",
|
||||
"text-lg font-normal tabular-nums min-w-[35px] text-right",
|
||||
(log.quantity_change || 0) > 0 ? "text-green-500" : ((log.quantity_change || 0) < 0 ? "text-rose-500" : "text-primary/50")
|
||||
)}>
|
||||
{log.quantity_change ? (log.quantity_change > 0 ? `+${log.quantity_change}` : log.quantity_change) : (log.action.includes('DB') ? 'SYS' : '±')}
|
||||
@@ -96,18 +96,18 @@ export default function LogsTable({ logs, loading }: LogsTableProps) {
|
||||
|
||||
{/* Selected Log Modal */}
|
||||
{selectedLog && (
|
||||
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-background/90 animate-in fade-in duration-300">
|
||||
<div className="bg-surface border-t sm:border border-slate-800 rounded-t-[2.5rem] sm:rounded-[3rem] p-6 sm:p-10 max-w-lg w-full shadow-2xl space-y-8 animate-in slide-in-from-bottom-10 duration-300 overflow-hidden">
|
||||
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4 bg-background/90 animate-in fade-in duration-300">
|
||||
<div className="bg-surface border-t sm:border border-slate-800 rounded-t-[2.5rem] sm:rounded-[3rem] p-4 md:p-6 max-w-lg w-full shadow-2xl space-y-3 md:space-y-4 animate-in slide-in-from-bottom-10 duration-300 overflow-hidden">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-1 pr-4">
|
||||
<div className={cn(
|
||||
"text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold px-4 lg:px-5 xl:px-6 py-1.5 rounded-full border inline-block tracking-widest",
|
||||
"text-xs font-normal px-4 py-1.5 rounded-full border inline-block tracking-widest",
|
||||
selectedLog.action.includes('CHECK_IN') ? "bg-green-500/10 text-green-500 border-green-500/30" :
|
||||
(selectedLog.action.includes('TRASH') ? "bg-rose-500/10 text-rose-500 border-rose-500/30" : "bg-primary/10 text-primary border-primary/30")
|
||||
)}>
|
||||
{selectedLog.action}
|
||||
</div>
|
||||
<h2 className="text-2xl lg:text-3xl xl:text-4xl font-black text-white tracking-tight pt-2 leading-tight">
|
||||
<h2 className="text-2xl font-normal text-white tracking-tight pt-2 leading-tight">
|
||||
{selectedLog.resolved_name}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -117,14 +117,14 @@ export default function LogsTable({ logs, loading }: LogsTableProps) {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1 bg-background/50 p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl border border-slate-800/50">
|
||||
<p className="text-[11px] font-bold text-muted tracking-widest">Protocol Operator</p>
|
||||
<p className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black text-secondary">{selectedLog.username || 'Automated Process'}</p>
|
||||
<div className="space-y-1 bg-background/50 p-4 rounded-2xl border border-slate-800/50">
|
||||
<p className="text-[11px] font-normal text-muted tracking-widest">Protocol Operator</p>
|
||||
<p className="text-sm font-normal text-secondary">{selectedLog.username || 'Automated Process'}</p>
|
||||
</div>
|
||||
<div className="space-y-1 bg-background/50 p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl border border-slate-800/50">
|
||||
<p className="text-[11px] font-bold text-muted tracking-widest">Quantity Delta</p>
|
||||
<div className="space-y-1 bg-background/50 p-4 rounded-2xl border border-slate-800/50">
|
||||
<p className="text-[11px] font-normal text-muted tracking-widest">Quantity Delta</p>
|
||||
<p className={cn(
|
||||
"text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black tabular-nums",
|
||||
"text-xl font-normal tabular-nums",
|
||||
(selectedLog.quantity_change || 0) > 0 ? "text-green-500" : "text-rose-500"
|
||||
)}>
|
||||
{selectedLog.quantity_change
|
||||
@@ -136,8 +136,8 @@ export default function LogsTable({ logs, loading }: LogsTableProps) {
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-bold text-muted tracking-widest ml-1">Universal Timestamp</p>
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-secondary bg-background/30 p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl border border-slate-800/30 tabular-nums">
|
||||
<p className="text-[11px] font-normal text-muted tracking-widest ml-1">Universal Timestamp</p>
|
||||
<p className="text-xs font-normal text-secondary bg-background/30 p-4 rounded-2xl border border-slate-800/30 tabular-nums">
|
||||
{new Date(selectedLog.timestamp).toLocaleString(undefined, { dateStyle: 'full', timeStyle: 'medium' })}
|
||||
</p>
|
||||
</div>
|
||||
@@ -149,15 +149,15 @@ export default function LogsTable({ logs, loading }: LogsTableProps) {
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-slate-800/50" />
|
||||
<p className="text-[8px] font-black text-slate-700 tracking-[0.2em]">Snapshot Record</p>
|
||||
<p className="text-[8px] font-normal text-slate-700 tracking-[0.2em]">Snapshot Record</p>
|
||||
<div className="h-px flex-1 bg-slate-800/50" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{Object.entries(snap).map(([key, val]) => (
|
||||
(val && key !== 'image_url' && key !== 'id') ? (
|
||||
<div key={key} className="bg-background/20 p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 rounded-xl border border-slate-800/20">
|
||||
<p className="text-[10px] font-bold text-muted mb-1 tracking-tight opacity-70">{key.replace('_', ' ')}</p>
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-secondary truncate" title={String(val)}>{String(val)}</p>
|
||||
<div key={key} className="bg-background/20 p-3 rounded-xl border border-slate-800/20">
|
||||
<p className="text-[10px] font-normal text-muted mb-1 tracking-tight opacity-70">{key.replace('_', ' ')}</p>
|
||||
<p className="text-xs font-normal text-secondary truncate" title={String(val)}>{String(val)}</p>
|
||||
</div>
|
||||
) : null
|
||||
))}
|
||||
@@ -169,8 +169,8 @@ export default function LogsTable({ logs, loading }: LogsTableProps) {
|
||||
|
||||
{selectedLog.details && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] font-bold text-muted tracking-widest ml-1">Intervention Details</p>
|
||||
<div className="bg-primary/5 text-primary/80 p-5 lg:p-6 xl:p-8 rounded-3xl border border-primary/10 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-medium leading-relaxed italic shadow-inner">
|
||||
<p className="text-[11px] font-normal text-muted tracking-widest ml-1">Intervention Details</p>
|
||||
<div className="bg-primary/5 text-primary/80 p-5 rounded-3xl border border-primary/10 text-sm font-medium leading-relaxed italic shadow-inner">
|
||||
"{selectedLog.details}"
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,7 +179,7 @@ export default function LogsTable({ logs, loading }: LogsTableProps) {
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedLog(null)}
|
||||
className="w-full bg-slate-800 hover:bg-slate-700 text-white font-black py-4.5 rounded-2xl transition-all active:scale-95 border border-slate-700 shadow-xl"
|
||||
className="w-full bg-slate-800 hover:bg-slate-700 text-white font-normal py-4.5 rounded-2xl transition-all active:scale-95 border border-slate-700 shadow-xl"
|
||||
>
|
||||
Close Audit Insight
|
||||
</button>
|
||||
|
||||
316
frontend/components/ManualCropUI.tsx
Normal file
316
frontend/components/ManualCropUI.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useCropHandles, type CropBounds, type HandleType } from '@/hooks/useCropHandles';
|
||||
|
||||
interface ManualCropUIProps {
|
||||
imageUrl: string;
|
||||
onCropChange: (bounds: CropBounds | null) => void;
|
||||
imageDimensions?: { width: number; height: number };
|
||||
initialCrop?: CropBounds;
|
||||
}
|
||||
|
||||
const HANDLE_SIZE = 12;
|
||||
const HANDLE_TYPES: HandleType[] = [
|
||||
'top-left',
|
||||
'top',
|
||||
'top-right',
|
||||
'right',
|
||||
'bottom-right',
|
||||
'bottom',
|
||||
'bottom-left',
|
||||
'left',
|
||||
];
|
||||
|
||||
export default function ManualCropUI({
|
||||
imageUrl,
|
||||
onCropChange,
|
||||
imageDimensions,
|
||||
initialCrop,
|
||||
}: ManualCropUIProps) {
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [displayDimensions, setDisplayDimensions] = useState<{ width: number; height: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const actualDimensions = imageDimensions || displayDimensions;
|
||||
|
||||
const { crop, setCrop, startDrag, moveDrag, endDrag, resetCrop, isDragging } = useCropHandles(
|
||||
actualDimensions
|
||||
? {
|
||||
imageDimensions: actualDimensions,
|
||||
initialCrop,
|
||||
minSize: 100,
|
||||
}
|
||||
: { imageDimensions: { width: 1, height: 1 }, initialCrop, minSize: 100 }
|
||||
);
|
||||
|
||||
// Measure image dimensions on load
|
||||
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e.currentTarget;
|
||||
const width = img.naturalWidth || img.width;
|
||||
const height = img.naturalHeight || img.height;
|
||||
|
||||
if (width && height) {
|
||||
setDisplayDimensions({ width, height });
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageError = () => {
|
||||
setError('Failed to load image');
|
||||
};
|
||||
|
||||
// Emit crop changes to parent
|
||||
useEffect(() => {
|
||||
onCropChange(crop);
|
||||
}, [crop, onCropChange]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 bg-slate-900 rounded-lg">
|
||||
<div className="text-center">
|
||||
<p className="text-rose-500">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If no dimensions yet, show minimal loading state with hidden image
|
||||
if (!actualDimensions) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="relative bg-slate-900 rounded-lg overflow-hidden border border-slate-800">
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={imageUrl}
|
||||
alt="Photo preview"
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
className="w-full h-full object-contain"
|
||||
style={{ visibility: 'hidden' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-slate-400 text-center">Loading image...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerWidth = containerRef.current?.clientWidth || 400;
|
||||
const scale = containerWidth / actualDimensions.width;
|
||||
|
||||
const displayWidth = actualDimensions.width * scale;
|
||||
const displayHeight = actualDimensions.height * scale;
|
||||
|
||||
const getHandlePosition = (
|
||||
handleType: HandleType
|
||||
): { left: string; top: string; transform: string } => {
|
||||
if (!crop) {
|
||||
return { left: '0', top: '0', transform: 'translate(0, 0)' };
|
||||
}
|
||||
|
||||
const x = crop.x * scale;
|
||||
const y = crop.y * scale;
|
||||
const w = crop.width * scale;
|
||||
const h = crop.height * scale;
|
||||
|
||||
const offsets = {
|
||||
'top-left': { left: x, top: y },
|
||||
top: { left: x + w / 2, top: y },
|
||||
'top-right': { left: x + w, top: y },
|
||||
right: { left: x + w, top: y + h / 2 },
|
||||
'bottom-right': { left: x + w, top: y + h },
|
||||
bottom: { left: x + w / 2, top: y + h },
|
||||
'bottom-left': { left: x, top: y + h },
|
||||
left: { left: x, top: y + h / 2 },
|
||||
};
|
||||
|
||||
const pos = offsets[handleType];
|
||||
return {
|
||||
left: `${pos.left}px`,
|
||||
top: `${pos.top}px`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
};
|
||||
};
|
||||
|
||||
const handleMouseDown = (handleType: HandleType) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (!containerRef.current || !crop) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const startX = (e.clientX - rect.left) / scale;
|
||||
const startY = (e.clientY - rect.top) / scale;
|
||||
|
||||
startDrag(handleType, startX, startY);
|
||||
};
|
||||
|
||||
const handleTouchStart = (handleType: HandleType) => (e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
if (!containerRef.current || !crop || e.touches.length === 0) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const touch = e.touches[0];
|
||||
const startX = (touch.clientX - rect.left) / scale;
|
||||
const startY = (touch.clientY - rect.top) / scale;
|
||||
|
||||
startDrag(handleType, startX, startY);
|
||||
};
|
||||
|
||||
// Global mouse/touch move and end listeners
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const currentX = (e.clientX - rect.left) / scale;
|
||||
const currentY = (e.clientY - rect.top) / scale;
|
||||
moveDrag(currentX, currentY);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (!containerRef.current || e.touches.length === 0) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const touch = e.touches[0];
|
||||
const currentX = (touch.clientX - rect.left) / scale;
|
||||
const currentY = (touch.clientY - rect.top) / scale;
|
||||
moveDrag(currentX, currentY);
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
endDrag();
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleEnd);
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleEnd);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleEnd);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleEnd);
|
||||
};
|
||||
}, [isDragging, scale, moveDrag, endDrag]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Image container with crop preview */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative bg-slate-900 rounded-lg overflow-hidden border border-slate-800"
|
||||
style={{
|
||||
aspectRatio: `${actualDimensions.width} / ${actualDimensions.height}`,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Image */}
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={imageUrl}
|
||||
alt="Photo preview"
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
|
||||
{/* Semi-transparent overlay outside crop box */}
|
||||
{crop && (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{/* Top overlay */}
|
||||
<div
|
||||
className="absolute left-0 right-0 bg-black/40"
|
||||
style={{
|
||||
top: 0,
|
||||
height: `${crop.y * scale}px`,
|
||||
}}
|
||||
/>
|
||||
{/* Bottom overlay */}
|
||||
<div
|
||||
className="absolute left-0 right-0 bg-black/40"
|
||||
style={{
|
||||
top: `${(crop.y + crop.height) * scale}px`,
|
||||
bottom: 0,
|
||||
}}
|
||||
/>
|
||||
{/* Left overlay */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 bg-black/40"
|
||||
style={{
|
||||
left: 0,
|
||||
width: `${crop.x * scale}px`,
|
||||
top: `${crop.y * scale}px`,
|
||||
height: `${crop.height * scale}px`,
|
||||
}}
|
||||
/>
|
||||
{/* Right overlay */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 bg-black/40"
|
||||
style={{
|
||||
left: `${(crop.x + crop.width) * scale}px`,
|
||||
right: 0,
|
||||
top: `${crop.y * scale}px`,
|
||||
height: `${crop.height * scale}px`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Crop bounding box */}
|
||||
<div
|
||||
className="absolute border-2 border-cyan-400"
|
||||
style={{
|
||||
left: `${crop.x * scale}px`,
|
||||
top: `${crop.y * scale}px`,
|
||||
width: `${crop.width * scale}px`,
|
||||
height: `${crop.height * scale}px`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Handles */}
|
||||
{HANDLE_TYPES.map((handleType) => (
|
||||
<button
|
||||
key={handleType}
|
||||
onMouseDown={handleMouseDown(handleType)}
|
||||
onTouchStart={handleTouchStart(handleType)}
|
||||
className={`absolute w-${HANDLE_SIZE} h-${HANDLE_SIZE} bg-cyan-400 rounded-full border-2 border-white shadow-lg hover:scale-125 transition-transform cursor-grab active:cursor-grabbing ${
|
||||
isDragging ? 'scale-125' : ''
|
||||
}`}
|
||||
style={{
|
||||
...getHandlePosition(handleType),
|
||||
width: `${HANDLE_SIZE}px`,
|
||||
height: `${HANDLE_SIZE}px`,
|
||||
}}
|
||||
aria-label={`Drag ${handleType} handle`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex gap-2">
|
||||
{crop && (
|
||||
<button
|
||||
onClick={() => resetCrop()}
|
||||
className="flex items-center justify-center gap-2 flex-1 px-4 py-2.5 bg-slate-700 text-white rounded-lg font-normal text-base transition-colors hover:bg-slate-600"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
<span>Use Full Photo</span>
|
||||
</button>
|
||||
)}
|
||||
{!crop && (
|
||||
<div className="text-sm text-slate-400 text-center flex-1 py-2.5">
|
||||
Drag handles to adjust crop area
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Crop bounds display (debug) */}
|
||||
{crop && (
|
||||
<div className="text-xs text-slate-500 text-center">
|
||||
Crop: {Math.round(crop.x)}, {Math.round(crop.y)} | Size: {Math.round(crop.width)} x{' '}
|
||||
{Math.round(crop.height)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ interface NewItemDialogProps {
|
||||
|
||||
export default function NewItemDialog({ onScannerClick, onAddItemClick }: NewItemDialogProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center py-8 text-center gap-6">
|
||||
<div className="flex flex-col items-center py-3 md:py-4 text-center gap-3 md:gap-4">
|
||||
<button
|
||||
onClick={onScannerClick}
|
||||
className="w-24 h-24 rounded-full bg-primary/20 hover:bg-primary/30 border-2 border-primary border-dashed flex items-center justify-center group transition-all"
|
||||
@@ -23,10 +23,10 @@ export default function NewItemDialog({ onScannerClick, onAddItemClick }: NewIte
|
||||
<Smartphone className="w-10 h-10 text-primary group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
|
||||
<div className="w-full flex justify-center mt-4">
|
||||
<div className="w-full flex justify-center">
|
||||
<button
|
||||
onClick={onAddItemClick}
|
||||
className="w-full flex flex-col items-center justify-center p-8 rounded-[2rem] bg-indigo-500/5 border border-indigo-500/20 group hover:border-indigo-500/50 transition-all font-black text-indigo-400 gap-4"
|
||||
className="w-full flex flex-col items-center justify-center p-4 md:p-6 rounded-[2rem] bg-indigo-500/5 border border-indigo-500/20 group hover:border-indigo-500/50 transition-all font-normal text-indigo-400 gap-2 md:gap-3"
|
||||
>
|
||||
<div className="p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-indigo-500/10 rounded-2xl group-hover:scale-110 transition-transform shadow-lg shadow-indigo-500/10">
|
||||
<Sparkles size={32} />
|
||||
|
||||
64
frontend/components/PhotoModal.tsx
Normal file
64
frontend/components/PhotoModal.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface PhotoModalProps {
|
||||
photoUrl: string;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export default function PhotoModal({
|
||||
photoUrl,
|
||||
onClose,
|
||||
title = 'Photo',
|
||||
}: PhotoModalProps) {
|
||||
useEffect(() => {
|
||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleEscapeKey);
|
||||
return () => window.removeEventListener('keydown', handleEscapeKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Photo viewer for ${title}`}
|
||||
>
|
||||
<div
|
||||
className="bg-surface border border-slate-800 rounded-3xl max-w-2xl w-full max-h-[90vh] overflow-auto flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-surface border-b border-slate-800/50 p-4 md:p-6 flex items-center justify-between">
|
||||
<h2 className="text-xl md:text-2xl font-normal text-white truncate">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-800 rounded-full text-muted hover:text-white transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X size={20} className="text-rose-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Image Container */}
|
||||
<div className="p-4 md:p-6 flex items-center justify-center flex-1">
|
||||
<img
|
||||
src={photoUrl}
|
||||
alt={title}
|
||||
className="max-w-full max-h-[calc(90vh-120px)] object-contain rounded-2xl"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export default function ScannerSection({
|
||||
onAddItemClick,
|
||||
}: ScannerSectionProps) {
|
||||
return (
|
||||
<div className="w-full px-1 space-y-6">
|
||||
<div className="w-full px-1 space-y-3 md:space-y-4">
|
||||
{/* Mode Switcher */}
|
||||
<div className="flex p-1.5 bg-surface rounded-2xl shadow-inner w-full gap-1">
|
||||
{[
|
||||
@@ -43,7 +43,7 @@ export default function ScannerSection({
|
||||
data-testid={m.id === 'CHECK_IN' ? 'operation-checkin' : m.id === 'CHECK_OUT' ? 'operation-checkout' : undefined}
|
||||
onClick={() => onModeChange(m.id)}
|
||||
className={cn(
|
||||
"flex-1 py-3 lg:py-4 xl:py-5.5 rounded-xl text-xs sm:text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black transition-all flex items-center justify-center gap-3",
|
||||
"flex-1 py-3.5 rounded-xl text-xs sm:text-sm font-normal transition-all flex items-center justify-center gap-3",
|
||||
mode === m.id ? "bg-slate-800 text-primary shadow-lg ring-1 ring-primary/20" : "text-muted hover:text-secondary"
|
||||
)}
|
||||
>
|
||||
@@ -56,9 +56,9 @@ export default function ScannerSection({
|
||||
{/* Scanner Section */}
|
||||
<section className="glass-card rounded-3xl p-6 lg:p-8 xl:p-10">
|
||||
{showScanner ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 md:space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-semibold">scanning...</h2>
|
||||
<h2 className="text-lg font-normal">scanning...</h2>
|
||||
<button
|
||||
onClick={() => onShowScanner(false)}
|
||||
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10.5 bg-surface border border-slate-800 text-secondary rounded-xl hover:text-rose-500 transition-all active:scale-95"
|
||||
|
||||
39
frontend/components/SearchErrorModal.tsx
Normal file
39
frontend/components/SearchErrorModal.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
interface SearchErrorModalProps {
|
||||
isOpen: boolean;
|
||||
error: string | null;
|
||||
onRetry?: () => void;
|
||||
onSkip?: () => void;
|
||||
canRetry?: boolean;
|
||||
}
|
||||
|
||||
export function SearchErrorModal({ isOpen, error, onRetry, onSkip, canRetry = true }: SearchErrorModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md shadow-xl">
|
||||
<h2 className="text-xl font-normal mb-2 text-rose-500">Search failed</h2>
|
||||
<p className="text-sm text-slate-600 mb-4">{error || 'Unable to retrieve specs from the web'}</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{canRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="flex-1 px-4 py-2 bg-primary text-white rounded font-normal text-sm hover:bg-primary/90"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className="flex-1 px-4 py-2 border border-slate-300 rounded font-normal text-sm hover:bg-slate-50"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
frontend/components/SearchLoadingModal.tsx
Normal file
57
frontend/components/SearchLoadingModal.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface SearchLoadingModalProps {
|
||||
isOpen: boolean;
|
||||
onTimeout?: () => void;
|
||||
maxSeconds?: number;
|
||||
}
|
||||
|
||||
export function SearchLoadingModal({ isOpen, onTimeout, maxSeconds = 30 }: SearchLoadingModalProps) {
|
||||
const [seconds, setSeconds] = useState(maxSeconds);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSeconds(maxSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setSeconds(prev => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(interval);
|
||||
onTimeout?.();
|
||||
return maxSeconds;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isOpen, maxSeconds, onTimeout]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md shadow-xl">
|
||||
<h2 className="text-xl font-normal mb-4">Searching for specs...</h2>
|
||||
<p className="text-sm text-slate-600 mb-4">This may take up to {maxSeconds} seconds</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="relative h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-1000"
|
||||
style={{ width: `${(seconds / maxSeconds) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm font-normal">
|
||||
<span className="text-primary text-lg">{seconds}</span> seconds remaining
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,12 +12,12 @@ export default function StatCard({ label, value, icon: Icon }: StatCardProps) {
|
||||
<div className="flex justify-between items-center gap-2 p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8.5 bg-surface/70 border border-slate-800/50 rounded-2xl shadow-sm transition-all hover:bg-surface" role="status">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
{Icon && <Icon className="w-5 h-5 text-primary flex-shrink-0" aria-hidden="true" />}
|
||||
<span className="text-base md:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-secondary font-semibold truncate">
|
||||
<span className="text-base md:text-lg text-secondary font-normal truncate">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-2xl md:text-3xl lg:text-4xl xl:text-5xl font-black text-white whitespace-nowrap tabular-nums">
|
||||
<span className="text-2xl md:text-3xl font-normal text-white whitespace-nowrap tabular-nums">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -62,13 +62,13 @@ export default function StockAdjustmentPanel({
|
||||
if (!selectedItem) return null;
|
||||
|
||||
return (
|
||||
<div data-testid="stock-adjustment-form" className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-background/80 animate-in fade-in duration-200">
|
||||
<div className="w-full max-w-lg bg-surface border border-slate-800 rounded-[2.5rem] shadow-2xl p-6 lg:p-8 xl:p-10 overflow-hidden animate-in slide-in-from-bottom-10 duration-300">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black tracking-tight flex items-center gap-2">
|
||||
<div data-testid="stock-adjustment-form" className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-background/80 animate-in fade-in duration-200">
|
||||
<div className="w-full max-w-lg bg-surface border border-slate-800 rounded-[2.5rem] shadow-2xl p-4 md:p-6 overflow-hidden animate-in slide-in-from-bottom-10 duration-300">
|
||||
<div className="flex justify-between items-center mb-3 md:mb-4">
|
||||
<h3 className="text-xl font-normal tracking-tight flex items-center gap-2">
|
||||
<span data-testid="adjustment-item-name">{isEditing ? "Edit Metadata" : selectedItem.name}</span>
|
||||
{!isEditing && (
|
||||
<span data-testid="current-quantity" className="text-[11px] bg-slate-800 text-secondary px-2 py-0.5 rounded-md font-black tracking-tight shadow-sm border border-slate-700/50">
|
||||
<span data-testid="current-quantity" className="text-[11px] bg-slate-800 text-secondary px-2 py-0.5 rounded-md font-normal tracking-tight shadow-sm border border-slate-700/50">
|
||||
In Stock: {selectedItem.quantity}
|
||||
</span>
|
||||
)}
|
||||
@@ -101,20 +101,20 @@ export default function StockAdjustmentPanel({
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-4 mb-8">
|
||||
<div className="space-y-2 md:space-y-3 mb-4 md:mb-6">
|
||||
<div>
|
||||
<div className="bg-surface py-2 lg:py-3 xl:py-4.5 px-4 lg:px-5 xl:px-6 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-secondary font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Item Name</label>
|
||||
<div className="bg-surface py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-secondary font-normal mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Item Name</label>
|
||||
<textarea
|
||||
value={editedItem.name || ''}
|
||||
onChange={(e) => onEditChange({ ...editedItem, name: e.target.value })}
|
||||
className="bg-transparent w-full text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold outline-none text-white placeholder:text-muted resize-none h-8 leading-tight selection:bg-primary/30 py-0"
|
||||
className="bg-transparent w-full text-lg font-normal outline-none text-white placeholder:text-muted resize-none h-8 leading-tight selection:bg-primary/30 py-0"
|
||||
placeholder="SSD, SFP, Cable..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary ml-1 tracking-tight">Part Number</label>
|
||||
<label className="text-sm font-normal text-secondary ml-1 tracking-tight">Part Number</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedItem.part_number || ''}
|
||||
@@ -125,7 +125,7 @@ export default function StockAdjustmentPanel({
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary ml-1 tracking-tight">Category Group</label>
|
||||
<label className="text-sm font-normal text-secondary ml-1 tracking-tight">Category Group</label>
|
||||
<input
|
||||
type="text"
|
||||
list="existing-categories"
|
||||
@@ -141,7 +141,7 @@ export default function StockAdjustmentPanel({
|
||||
</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary ml-1 tracking-tight">Item Type</label>
|
||||
<label className="text-sm font-normal text-secondary ml-1 tracking-tight">Item Type</label>
|
||||
<input
|
||||
type="text"
|
||||
list="existing-types"
|
||||
@@ -152,7 +152,7 @@ export default function StockAdjustmentPanel({
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary ml-1">Box / Container Label</label>
|
||||
<label className="text-sm font-normal text-secondary ml-1">Box / Container Label</label>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
@@ -178,7 +178,7 @@ export default function StockAdjustmentPanel({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary ml-1 tracking-tight">Connector</label>
|
||||
<label className="text-sm font-normal text-secondary ml-1 tracking-tight">Connector</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedItem.connector || ''}
|
||||
@@ -188,7 +188,7 @@ export default function StockAdjustmentPanel({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary ml-1 tracking-tight">Size / Length</label>
|
||||
<label className="text-sm font-normal text-secondary ml-1 tracking-tight">Size / Length</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedItem.size || ''}
|
||||
@@ -198,7 +198,7 @@ export default function StockAdjustmentPanel({
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary ml-1 tracking-tight">Item Color</label>
|
||||
<label className="text-sm font-normal text-secondary ml-1 tracking-tight">Item Color</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedItem.color || ''}
|
||||
@@ -208,7 +208,7 @@ export default function StockAdjustmentPanel({
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary ml-1">Description</label>
|
||||
<label className="text-sm font-normal text-secondary ml-1">Description</label>
|
||||
<textarea
|
||||
value={editedItem.description || ''}
|
||||
onChange={e => onEditChange({ ...editedItem, description: e.target.value })}
|
||||
@@ -216,12 +216,12 @@ export default function StockAdjustmentPanel({
|
||||
placeholder="Item description..."
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-surface py-2 lg:py-3 xl:py-4.5 px-4 lg:px-5 xl:px-6 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-secondary font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Item ID or Code</label>
|
||||
<div className="bg-surface py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-secondary font-normal mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Item ID or Code</label>
|
||||
<textarea
|
||||
value={editedItem.ocr_text || ''}
|
||||
onChange={e => onEditChange({ ...editedItem, ocr_text: e.target.value })}
|
||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold outline-none text-secondary resize-none h-12"
|
||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm font-normal outline-none text-secondary resize-none h-12"
|
||||
placeholder="e.g., SKU-12345 or barcode text..."
|
||||
/>
|
||||
</div>
|
||||
@@ -229,7 +229,7 @@ export default function StockAdjustmentPanel({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex p-1 bg-background rounded-2xl mb-8">
|
||||
<div className="flex p-1 bg-background rounded-2xl mb-4 md:mb-6">
|
||||
{[
|
||||
{ id: 'ADD', label: 'Buy More', icon: Plus, color: 'text-primary' },
|
||||
{ id: 'REMOVE', label: 'Subtract', icon: Minus, color: 'text-amber-500' },
|
||||
@@ -244,13 +244,13 @@ export default function StockAdjustmentPanel({
|
||||
)}
|
||||
>
|
||||
<t.icon size={20} className={adjustType === t.id ? t.color : ""} />
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black mt-1">{t.label}</span>
|
||||
<span className="text-xs font-normal mt-1">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-6 mb-8">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex flex-col items-center gap-3 md:gap-4 mb-4 md:mb-6">
|
||||
<div className="flex items-center gap-2 md:gap-4">
|
||||
<button
|
||||
onClick={() => onQuantityChange(Math.max(1, adjustQty - 1))}
|
||||
className="w-12 h-12 rounded-full border border-slate-800 flex items-center justify-center text-secondary active:bg-slate-800"
|
||||
@@ -258,8 +258,8 @@ export default function StockAdjustmentPanel({
|
||||
<Minus size={24} />
|
||||
</button>
|
||||
<div className="text-center" data-testid="adjustment-quantity-input">
|
||||
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black tabular-nums">{adjustQty}</span>
|
||||
<span className="text-[10px] text-primary/80 font-bold tracking-tight">Units</span>
|
||||
<span className="text-xs font-normal tabular-nums">{adjustQty}</span>
|
||||
<span className="text-[10px] text-primary/80 font-normal tracking-tight">Units</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onQuantityChange(adjustQty + 1)}
|
||||
@@ -273,7 +273,7 @@ export default function StockAdjustmentPanel({
|
||||
<div className="w-full bg-red-500/5 border border-red-500/20 p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl animate-in shake duration-500">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<AlertTriangle size={16} className="text-red-500" />
|
||||
<span className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-red-400">Waste Declaration</span>
|
||||
<span className="text-sm font-normal text-red-400">Waste Declaration</span>
|
||||
</div>
|
||||
<select
|
||||
value={trashReason}
|
||||
@@ -296,7 +296,7 @@ export default function StockAdjustmentPanel({
|
||||
onClick={isEditing ? onUpdateItem : onAdjustStock}
|
||||
data-testid="adjustment-submit"
|
||||
className={cn(
|
||||
"w-full py-5 rounded-[1.8rem] font-black text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl transition-all active:scale-[0.98] shadow-2xl",
|
||||
"w-full py-5 rounded-[1.8rem] font-normal text-lg transition-all active:scale-[0.98] shadow-2xl",
|
||||
isEditing ? "bg-slate-100 text-slate-900" : (
|
||||
adjustType === 'ADD' ? "bg-primary shadow-primary/20 text-white" :
|
||||
adjustType === 'REMOVE' ? "bg-amber-600 shadow-amber-500/20 text-white" :
|
||||
|
||||
34
frontend/components/Toast.tsx
Normal file
34
frontend/components/Toast.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface ToastProps {
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export function Toast({ type, message, onClose, duration = 3000 }: ToastProps) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onClose, duration);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onClose, duration]);
|
||||
|
||||
const bgColor = {
|
||||
success: 'bg-green-500',
|
||||
error: 'bg-red-500',
|
||||
warning: 'bg-yellow-500',
|
||||
info: 'bg-blue-500',
|
||||
}[type];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-4 right-4 ${bgColor} text-white px-4 py-2 rounded shadow-lg z-50 animate-fade-in`}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,17 +32,17 @@ export default function AiManager({
|
||||
onUpdatePrompt
|
||||
}: AiManagerProps) {
|
||||
return (
|
||||
<section data-testid="ai-config" className="bg-surface/50 border border-slate-800/50 rounded-[2.5rem] p-5 md:p-8 shadow-2xl space-y-6 transition-all group/ai">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<section data-testid="ai-config" className="bg-surface/50 border border-slate-800/50 rounded-[2.5rem] p-4 md:p-6 shadow-2xl space-y-4 transition-all group/ai">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary border border-primary/20">
|
||||
<Brain size={20} />
|
||||
</div>
|
||||
<h2 className="text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-white tracking-tight">AI Intelligence</h2>
|
||||
<h2 className="text-xl font-normal text-white tracking-tight">AI Intelligence</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
{aiConfig?.providers?.map((p: any) => (
|
||||
<button
|
||||
key={p.id}
|
||||
@@ -63,9 +63,9 @@ export default function AiManager({
|
||||
{p.id === 'gemini' ? <Cpu size={16} /> : <Zap size={16} />}
|
||||
</div>
|
||||
<div>
|
||||
<p className={cn("text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black tracking-tight", p.active ? "text-white" : "text-secondary")}>{p.name}</p>
|
||||
<p className={cn("text-xs font-normal tracking-tight", p.active ? "text-white" : "text-secondary")}>{p.name}</p>
|
||||
<p className={cn(
|
||||
"text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold mt-1 px-0.5 rounded",
|
||||
"text-xs font-normal mt-1 px-0.5 rounded",
|
||||
p.active
|
||||
? (p.configured ? "text-emerald-200" : "text-rose-100")
|
||||
: (p.configured ? "text-emerald-500" : "text-rose-500")
|
||||
@@ -75,7 +75,7 @@ export default function AiManager({
|
||||
</div>
|
||||
</div>
|
||||
{p.active && (
|
||||
<div className="bg-white/20 px-2.5 py-1 rounded-full text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-white tracking-tight">
|
||||
<div className="bg-white/20 px-2.5 py-1 rounded-full text-xs font-normal text-white tracking-tight">
|
||||
Active
|
||||
</div>
|
||||
)}
|
||||
@@ -83,26 +83,26 @@ export default function AiManager({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-primary/5 border border-primary/10 rounded-[2rem] p-6 lg:p-8 xl:p-10 space-y-6">
|
||||
<div className="bg-primary/5 border border-primary/10 rounded-[2rem] p-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-primary/10 rounded-lg text-primary"><Lock size={14} /></div>
|
||||
<h3 className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary tracking-tight">Provider Access Keys</h3>
|
||||
<div className="p-2 bg-primary/10 rounded-lg text-primary"><Lock size={14} /></div>
|
||||
<h3 className="text-sm font-normal text-secondary tracking-tight">Provider Access Keys</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onSaveAiKeys}
|
||||
disabled={isSavingKeys}
|
||||
data-testid="save-settings-button"
|
||||
className="px-6 py-2 lg:py-3 xl:py-4.5 bg-primary hover:bg-primary text-white rounded-xl text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black transition-all active:scale-95 shadow-xl shadow-primary/10 tracking-tight border border-primary/30 flex items-center gap-2"
|
||||
className="px-5 py-2 bg-primary hover:bg-primary text-white rounded-xl text-sm font-normal transition-all active:scale-95 shadow-xl shadow-primary/10 tracking-tight border border-primary/30 flex items-center gap-2"
|
||||
>
|
||||
{isSavingKeys ? <RotateCcw size={14} className="animate-spin" /> : <Lock size={14} />}
|
||||
{isSavingKeys ? "Storing..." : "Store API Keys"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary tracking-tight ml-1">Gemini Api Key</label>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-normal text-secondary tracking-tight ml-1">Gemini Api Key</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
data-testid="ai-api-key-input"
|
||||
@@ -110,14 +110,14 @@ export default function AiManager({
|
||||
value={aiKeys.gemini}
|
||||
onChange={(e) => setAiKeys({...aiKeys, gemini: e.target.value})}
|
||||
placeholder={aiConfig?.providers?.find((p: any) => p.id === 'gemini')?.masked_key || "Enter Gemini Key..."}
|
||||
className="flex-1 bg-background/80 border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-white outline-none focus:border-primary/50 transition-all placeholder:text-secondary"
|
||||
className="flex-1 bg-background/80 border border-slate-800 rounded-xl py-1.5 px-4 text-xs text-white outline-none focus:border-primary/50 transition-all placeholder:text-secondary"
|
||||
/>
|
||||
<button
|
||||
onClick={() => onTestAiKey('gemini')}
|
||||
disabled={isTestingKeys.gemini}
|
||||
className={cn(
|
||||
"px-4 lg:px-5 xl:px-6 py-2 lg:py-3 xl:py-4 rounded-xl text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold tracking-tight transition-all border shadow-lg flex items-center gap-1.5",
|
||||
isTestingKeys.gemini
|
||||
"px-3 py-1.5 rounded-xl text-xs font-normal tracking-tight transition-all border shadow-lg flex items-center gap-1.5",
|
||||
isTestingKeys.gemini
|
||||
? "bg-slate-800 border-slate-700 text-muted cursor-wait"
|
||||
: "bg-primary border-primary text-white hover:bg-primary"
|
||||
)}
|
||||
@@ -127,8 +127,8 @@ export default function AiManager({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary tracking-tight ml-1">Claude Api Key</label>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-normal text-secondary tracking-tight ml-1">Claude Api Key</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
data-testid="ai-api-key-input"
|
||||
@@ -136,14 +136,14 @@ export default function AiManager({
|
||||
value={aiKeys.claude}
|
||||
onChange={(e) => setAiKeys({...aiKeys, claude: e.target.value})}
|
||||
placeholder={aiConfig?.providers?.find((p: any) => p.id === 'claude')?.masked_key || "Enter Claude Key..."}
|
||||
className="flex-1 bg-background/80 border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-white outline-none focus:border-primary/50 transition-all placeholder:text-secondary"
|
||||
className="flex-1 bg-background/80 border border-slate-800 rounded-xl py-1.5 px-4 text-xs text-white outline-none focus:border-primary/50 transition-all placeholder:text-secondary"
|
||||
/>
|
||||
<button
|
||||
onClick={() => onTestAiKey('claude')}
|
||||
disabled={isTestingKeys.claude}
|
||||
className={cn(
|
||||
"px-4 lg:px-5 xl:px-6 py-2 lg:py-3 xl:py-4 rounded-xl text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold tracking-tight transition-all border shadow-lg flex items-center gap-1.5",
|
||||
isTestingKeys.claude
|
||||
"px-3 py-1.5 rounded-xl text-xs font-normal tracking-tight transition-all border shadow-lg flex items-center gap-1.5",
|
||||
isTestingKeys.claude
|
||||
? "bg-slate-800 border-slate-700 text-muted cursor-wait"
|
||||
: "bg-primary border-primary text-white hover:bg-primary"
|
||||
)}
|
||||
@@ -156,28 +156,28 @@ export default function AiManager({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 pt-0">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary tracking-tight">System Prompt (Vision Extraction)</label>
|
||||
<button
|
||||
<label className="text-sm font-normal text-secondary tracking-tight">System Prompt (Vision Extraction)</label>
|
||||
<button
|
||||
onClick={onUpdatePrompt}
|
||||
disabled={isSavingPrompt}
|
||||
className="px-6 py-2 lg:py-3 xl:py-4 bg-primary hover:bg-primary text-white rounded-xl text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black transition-all active:scale-95 shadow-xl shadow-primary/10 tracking-tight border border-primary/30 flex items-center gap-2"
|
||||
className="px-5 py-1.5 bg-primary hover:bg-primary text-white rounded-xl text-sm font-normal transition-all active:scale-95 shadow-xl shadow-primary/10 tracking-tight border border-primary/30 flex items-center gap-2"
|
||||
>
|
||||
{isSavingPrompt ? <RotateCcw size={14} className="animate-spin" /> : <FileText size={14} />}
|
||||
{isSavingPrompt ? "Saving..." : "Save Prompt"}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
<textarea
|
||||
value={aiPrompt}
|
||||
onChange={(e) => setAiPrompt(e.target.value)}
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-2xl p-6 lg:p-8 xl:p-10 text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-mono font-bold text-secondary leading-relaxed outline-none focus:border-purple-500/50 transition-all min-h-[200px] custom-scrollbar shadow-inner"
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-2xl p-4 text-xs font-mono font-normal text-secondary leading-relaxed outline-none focus:border-purple-500/50 transition-all min-h-[200px] custom-scrollbar shadow-inner"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-primary/5 border border-purple-500/10 rounded-2xl p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 flex gap-4 items-start">
|
||||
<div className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-primary/10 rounded-lg text-purple-400 shrink-0"><Shield size={14} /></div>
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-medium text-secondary leading-relaxed">
|
||||
<div className="bg-primary/5 border border-purple-500/10 rounded-2xl p-4 flex gap-3 items-start">
|
||||
<div className="p-2 bg-primary/10 rounded-lg text-purple-400 shrink-0"><Shield size={14} /></div>
|
||||
<p className="text-xs font-medium text-secondary leading-relaxed">
|
||||
This prompt instructs the Vision AI core on label interpretation. Ensure it defines explicit mapping for technical attributes like <span className="text-purple-400">Item</span>, <span className="text-purple-400">Type</span>, and <span className="text-purple-400">Part Number</span> to avoid extraction null-pointers and ensure inventory data integrity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -23,24 +23,24 @@ export default function CategoryManager({
|
||||
onUpdateCategorySubmit
|
||||
}: CategoryManagerProps) {
|
||||
return (
|
||||
<section className="bg-surface/50 border border-slate-800/50 rounded-[2.5rem] p-5 md:p-8 shadow-2xl space-y-6 transition-all group/categories">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<section className="bg-surface/50 border border-slate-800/50 rounded-[2.5rem] p-4 md:p-6 shadow-2xl space-y-4 transition-all group/categories">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary border border-primary/20">
|
||||
<Layers size={20} />
|
||||
</div>
|
||||
<h2 className="text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-white tracking-tight">Category Groups</h2>
|
||||
<h2 className="text-xl font-normal text-white tracking-tight">Category Groups</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onAddCategory}
|
||||
data-testid="add-category-button"
|
||||
className="flex items-center gap-2 bg-primary hover:bg-primary text-white px-6 py-3 lg:py-4 xl:py-5 rounded-xl text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black transition-all shadow-xl shadow-primary/10 active:scale-95 border border-primary/30 tracking-tight"
|
||||
className="flex items-center gap-2 bg-primary hover:bg-primary text-white px-5 py-2 rounded-xl text-sm font-normal transition-all shadow-xl shadow-primary/10 active:scale-95 border border-primary/30 tracking-tight"
|
||||
>
|
||||
<Plus size={14} /> New Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div data-testid="category-list" className="grid sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div data-testid="category-list" className="grid sm:grid-cols-2 lg:grid-cols-4 gap-2.5">
|
||||
{categories.map(cat => (
|
||||
<div key={cat.id} data-testid="category-item" className="p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-background/40 border border-slate-800/50 rounded-2xl flex items-center justify-between group hover:border-primary/40 transition-all">
|
||||
<div className="min-w-0 pr-4">
|
||||
@@ -71,39 +71,39 @@ export default function CategoryManager({
|
||||
|
||||
{/* Edit Category Modal */}
|
||||
{editingCategory && (
|
||||
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-background/90 animate-in fade-in duration-300">
|
||||
<div className="w-full max-w-lg bg-surface border-x border-t sm:border border-slate-800 rounded-t-[2.5rem] sm:rounded-3xl p-6 sm:p-10 shadow-2xl space-y-8 overflow-hidden animate-in slide-in-from-bottom-10">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4 bg-background/90 animate-in fade-in duration-300">
|
||||
<div className="w-full max-w-lg bg-surface border-x border-t sm:border border-slate-800 rounded-t-[2.5rem] sm:rounded-3xl p-6 shadow-2xl space-y-3 overflow-hidden animate-in slide-in-from-bottom-10">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary border border-primary/20">
|
||||
<Edit2 size={20} />
|
||||
</div>
|
||||
<h3 className="text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-white tracking-tight">Modify Group</h3>
|
||||
<h3 className="text-xl font-normal text-white tracking-tight">Modify Group</h3>
|
||||
</div>
|
||||
<button onClick={() => setEditingCategory(null)} className="p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 hover:bg-slate-800 rounded-2xl text-muted transition-colors border border-slate-800">
|
||||
<button onClick={() => setEditingCategory(null)} className="p-2 hover:bg-slate-800 rounded-2xl text-muted transition-colors border border-slate-800">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div data-testid="category-form" className="space-y-6">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary tracking-tight ml-1">Group Identifier</label>
|
||||
<div data-testid="category-form" className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-normal text-secondary tracking-tight ml-1">Group Identifier</label>
|
||||
<input
|
||||
data-testid="category-name-input"
|
||||
type="text"
|
||||
value={editCatForm.name}
|
||||
onChange={(e) => setEditCatForm({...editCatForm, name: e.target.value})}
|
||||
className="w-full bg-background border border-slate-800 rounded-2xl py-3 lg:py-4 xl:py-5.5 px-5 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-white focus:border-primary outline-none transition-all"
|
||||
className="w-full bg-background border border-slate-800 rounded-2xl py-1.5 px-4 text-sm text-white focus:border-primary outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary tracking-tight ml-1">Strategic Description</label>
|
||||
<textarea
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-normal text-secondary tracking-tight ml-1">Strategic Description</label>
|
||||
<textarea
|
||||
value={editCatForm.description}
|
||||
onChange={(e) => setEditCatForm({...editCatForm, description: e.target.value})}
|
||||
className="w-full bg-background border border-slate-800 rounded-2xl py-4 px-5 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-white focus:border-primary outline-none transition-all h-32 resize-none"
|
||||
className="w-full bg-background border border-slate-800 rounded-2xl py-1.5 px-4 text-sm text-white focus:border-primary outline-none transition-all h-24 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<button data-testid="add-category-submit" onClick={onUpdateCategorySubmit} className="w-full bg-primary hover:bg-primary text-white font-black py-4 rounded-2xl shadow-xl shadow-primary/20 active:scale-95 transition-all text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl mb-4 tracking-tight">
|
||||
<button data-testid="add-category-submit" onClick={onUpdateCategorySubmit} className="w-full bg-primary hover:bg-primary text-white font-normal py-2.5 rounded-2xl shadow-xl shadow-primary/20 active:scale-95 transition-all text-sm tracking-tight">
|
||||
Update Asset Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -34,33 +34,33 @@ export default function DatabaseManager({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 md:space-y-8 h-full">
|
||||
<div data-testid="database-info" className="bg-surface/50 border border-slate-800/50 rounded-[2.5rem] p-5 md:p-8 shadow-2xl overflow-hidden relative group transition-all">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="space-y-3 md:space-y-4 h-full">
|
||||
<div data-testid="database-info" className="bg-surface/50 border border-slate-800/50 rounded-[2.5rem] p-4 md:p-6 shadow-2xl overflow-hidden relative group transition-all">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary border border-primary/20">
|
||||
<Database size={20} />
|
||||
</div>
|
||||
<h2 className="text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-white tracking-tight">System Integrity</h2>
|
||||
<h2 className="text-xl font-normal text-white tracking-tight">System Integrity</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
|
||||
<div className="space-y-1 sm:pr-6">
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-muted tracking-tight">Database Health</p>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1 sm:pr-4">
|
||||
<p className="text-xs font-normal text-muted tracking-tight">Database Health</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.3)]" />
|
||||
<span className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary">Operational</span>
|
||||
<span className="text-sm font-normal text-secondary">Operational</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:pl-6 sm:border-l border-slate-800">
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-muted tracking-tight">Last Backup</p>
|
||||
<p className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary tabular-nums">{dbStats.backup_count > 0 ? 'Verified' : 'Pending...'}</p>
|
||||
<div className="sm:pl-4 sm:border-l border-slate-800">
|
||||
<p className="text-xs font-normal text-muted tracking-tight">Last Backup</p>
|
||||
<p className="text-sm font-normal text-secondary tabular-nums">{dbStats.backup_count > 0 ? 'Verified' : 'Pending...'}</p>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<button
|
||||
onClick={onCreateBackup}
|
||||
disabled={isBackingUp}
|
||||
data-testid="backup-button"
|
||||
className="flex items-center gap-2 px-5 py-3 lg:py-4 xl:py-5 bg-primary hover:bg-blue-600 text-white rounded-xl text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black transition-all active:scale-95 disabled:opacity-50 shadow-xl shadow-primary/10 tracking-tight focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-primary hover:bg-blue-600 text-white rounded-xl text-sm font-normal transition-all active:scale-95 disabled:opacity-50 shadow-xl shadow-primary/10 tracking-tight focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
||||
aria-label="Create database backup"
|
||||
>
|
||||
{isBackingUp ? <RotateCcw size={14} className="animate-spin" /> : <Database size={14} />}
|
||||
@@ -70,75 +70,75 @@ export default function DatabaseManager({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface/50 border border-slate-800/50 rounded-[2.5rem] p-5 md:p-8 shadow-2xl space-y-8">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-surface/50 border border-slate-800/50 rounded-[2.5rem] p-4 md:p-6 shadow-2xl space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary border border-primary/20">
|
||||
<History size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black text-white tracking-tight">Storage Policy</h3>
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-secondary font-bold tracking-tight">Retention & Maintenance</p>
|
||||
<h3 className="text-lg font-normal text-white tracking-tight">Storage Policy</h3>
|
||||
<p className="text-sm text-secondary font-normal tracking-tight">Retention & Maintenance</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-muted tracking-tight ml-1 flex items-center gap-2">
|
||||
<div className="grid sm:grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-normal text-muted tracking-tight ml-1 flex items-center gap-2">
|
||||
<History size={10} /> Max Backups
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
<input
|
||||
type="number"
|
||||
value={dbSettings.retention_count}
|
||||
onChange={(e) => onUpdateDbSettings({...dbSettings, retention_count: parseInt(e.target.value)})}
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-white outline-none focus:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none transition-all tabular-nums"
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-xl py-1.5 px-4 text-xs font-normal text-white outline-none focus:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none transition-all tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-muted tracking-tight ml-1 flex items-center gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-normal text-muted tracking-tight ml-1 flex items-center gap-2">
|
||||
<Clock size={10} /> Schedule (Hour)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
<input
|
||||
type="number"
|
||||
min="0" max="23"
|
||||
value={dbSettings.schedule_hour}
|
||||
onChange={(e) => onUpdateDbSettings({...dbSettings, schedule_hour: parseInt(e.target.value)})}
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-white outline-none focus:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none transition-all tabular-nums"
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-xl py-1.5 px-4 text-xs font-normal text-white outline-none focus:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none transition-all tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-muted tracking-tight ml-1 flex items-center gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-normal text-muted tracking-tight ml-1 flex items-center gap-2">
|
||||
<Zap size={10} /> Frequency (Days)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={dbSettings.schedule_freq_days}
|
||||
onChange={(e) => onUpdateDbSettings({...dbSettings, schedule_freq_days: parseInt(e.target.value)})}
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-white outline-none focus:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none transition-all tabular-nums"
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-xl py-1.5 px-4 text-xs font-normal text-white outline-none focus:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none transition-all tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<label className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-muted tracking-tight">Recovery Points</label>
|
||||
<div className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-primary tracking-tight bg-primary/5 px-3 lg:px-4 xl:px-5 py-1 rounded-full border border-primary/10">
|
||||
<label className="text-sm font-normal text-muted tracking-tight">Recovery Points</label>
|
||||
<div className="text-xs font-normal text-primary tracking-tight bg-primary/5 px-3 py-1 rounded-full border border-primary/10">
|
||||
{formatSize(dbStats.total_size_bytes)} Used
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pr-2 overflow-y-auto max-h-[300px] scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
|
||||
<div className="space-y-1 pr-2 overflow-y-auto max-h-[300px] scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
|
||||
{backups.map((bak: any) => (
|
||||
<div key={bak.filename} className="flex items-center justify-between p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8.5 bg-background/40 border border-slate-800/40 rounded-2xl group/item hover:border-primary/30 transition-all">
|
||||
<div key={bak.filename} className="flex items-center justify-between p-3 bg-background/40 border border-slate-800/40 rounded-2xl group/item hover:border-primary/30 transition-all">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-800 flex items-center justify-center text-muted">
|
||||
<Database size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-secondary">{bak.filename}</p>
|
||||
<p className="text-[11px] text-secondary font-medium tabular-nums">
|
||||
<p className="text-xs font-normal text-secondary">{bak.filename}</p>
|
||||
<p className="text-xs text-secondary font-medium tabular-nums">
|
||||
{new Date(bak.created_at).toLocaleString()} • {formatSize(bak.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -155,23 +155,23 @@ export default function DatabaseManager({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
<div className="grid grid-cols-2 gap-3 pt-1">
|
||||
<button
|
||||
onClick={onExport}
|
||||
className="flex items-center justify-center gap-2 py-4 bg-background border border-slate-800 hover:border-primary/50 text-primary rounded-2xl text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black transition-all active:scale-95 tracking-tight shadow-xl shadow-black/20 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
||||
className="flex items-center justify-center gap-2 py-2.5 bg-background border border-slate-800 hover:border-primary/50 text-primary rounded-2xl text-sm font-normal transition-all active:scale-95 tracking-tight shadow-xl shadow-black/20 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
||||
aria-label="Export database"
|
||||
>
|
||||
<Download size={14} /> Export Database
|
||||
</button>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
<input
|
||||
type="file"
|
||||
accept=".db"
|
||||
onChange={onImport}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer z-10"
|
||||
/>
|
||||
<button
|
||||
className="w-full flex items-center justify-center gap-2 py-4 bg-background border border-slate-800 hover:border-rose-500/50 text-rose-500 rounded-2xl text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black transition-all tracking-tight shadow-xl shadow-black/20 focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:outline-none"
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 bg-background border border-slate-800 hover:border-rose-500/50 text-rose-500 rounded-2xl text-sm font-normal transition-all tracking-tight shadow-xl shadow-black/20 focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:outline-none"
|
||||
aria-label="Import database"
|
||||
>
|
||||
<Upload size={14} /> Import Database
|
||||
|
||||
137
frontend/components/admin/ExportPanel.tsx
Normal file
137
frontend/components/admin/ExportPanel.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FileDown, Loader2 } from "lucide-react";
|
||||
import { useExport } from "@/hooks/useExport";
|
||||
import { Toast } from "@/components/Toast";
|
||||
|
||||
export function ExportPanel() {
|
||||
const { exportSnapshot, exportAuditTrail, isLoading, error } = useExport();
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
const handleExportSnapshot = async (format: "csv" | "xlsx") => {
|
||||
try {
|
||||
setSuccessMessage(null);
|
||||
await exportSnapshot(format);
|
||||
const formatName = format === "csv" ? "CSV" : "Excel";
|
||||
setSuccessMessage(`Inventory snapshot exported as ${formatName}`);
|
||||
setTimeout(() => setSuccessMessage(null), 4000);
|
||||
} catch (err) {
|
||||
// Error handled by useExport hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportAuditTrail = async (format: "csv" | "xlsx") => {
|
||||
try {
|
||||
setSuccessMessage(null);
|
||||
await exportAuditTrail(format);
|
||||
const formatName = format === "csv" ? "CSV" : "Excel";
|
||||
setSuccessMessage(`Audit trail exported as ${formatName}`);
|
||||
setTimeout(() => setSuccessMessage(null), 4000);
|
||||
} catch (err) {
|
||||
// Error handled by useExport hook
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-6">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="rounded-lg bg-primary/10 p-4 border border-primary/20">
|
||||
<FileDown className="text-primary" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-normal">Export & Reports</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Download inventory snapshots and audit trails in CSV or Excel formats
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inventory Snapshot Section */}
|
||||
<div className="mb-8 space-y-3">
|
||||
<h3 className="font-normal text-slate-700">Inventory Snapshot</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Export current inventory state with all item details
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
onClick={() => handleExportSnapshot("csv")}
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2 rounded-md bg-blue-500 px-4 py-2 text-sm font-normal text-white hover:bg-blue-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Export inventory snapshot as CSV"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<FileDown size={16} />
|
||||
)}
|
||||
{isLoading ? "Exporting..." : "Export as CSV"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExportSnapshot("xlsx")}
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2 rounded-md bg-green-500 px-4 py-2 text-sm font-normal text-white hover:bg-green-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Export inventory snapshot as Excel"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<FileDown size={16} />
|
||||
)}
|
||||
{isLoading ? "Exporting..." : "Export as Excel"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Audit Trail Section */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-normal text-slate-700">Audit Trail</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Export complete audit log of all system actions and changes
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
onClick={() => handleExportAuditTrail("csv")}
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2 rounded-md bg-blue-500 px-4 py-2 text-sm font-normal text-white hover:bg-blue-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Export audit trail as CSV"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<FileDown size={16} />
|
||||
)}
|
||||
{isLoading ? "Exporting..." : "Export as CSV"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExportAuditTrail("xlsx")}
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2 rounded-md bg-green-500 px-4 py-2 text-sm font-normal text-white hover:bg-green-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Export audit trail as Excel"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<FileDown size={16} />
|
||||
)}
|
||||
{isLoading ? "Exporting..." : "Export as Excel"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Messages */}
|
||||
{error && (
|
||||
<Toast
|
||||
type="error"
|
||||
message={`Export failed: ${error}`}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
)}
|
||||
{successMessage && (
|
||||
<Toast type="success" message={successMessage} onClose={() => {}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,27 +24,27 @@ export default function IdentityManager({
|
||||
onUpdateUserSubmit
|
||||
}: IdentityManagerProps) {
|
||||
return (
|
||||
<div className="bg-surface/50 border border-slate-800/50 rounded-[2.5rem] p-5 md:p-8 flex flex-col shadow-2xl transition-all group/identity h-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-surface/50 border border-slate-800/50 rounded-[2.5rem] p-4 md:p-6 flex flex-col shadow-2xl transition-all group/identity h-full">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary border border-primary/20">
|
||||
<User size={20} />
|
||||
</div>
|
||||
<h2 className="text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-white tracking-tight">Identity Management</h2>
|
||||
<h2 className="text-xl font-normal text-white tracking-tight">Identity Management</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onAddUser}
|
||||
data-testid="add-user-button"
|
||||
className="flex items-center gap-2 px-5 py-2 lg:py-3 xl:py-4.5 bg-primary hover:bg-blue-600 text-white rounded-xl text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black transition-all shadow-xl shadow-primary/10 active:scale-95 border border-primary/30 tracking-tight focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-primary hover:bg-blue-600 text-white rounded-xl text-sm font-normal transition-all shadow-xl shadow-primary/10 active:scale-95 border border-primary/30 tracking-tight focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
||||
aria-label="Add new user"
|
||||
>
|
||||
<UserPlus size={16} /> Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2.5 pr-2 -mr-2 overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
|
||||
<div className="flex-1 space-y-1 pr-2 -mr-2 overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
|
||||
{users.map((user) => (
|
||||
<div key={user.id} className="flex items-center justify-between p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8.5 bg-background/40 border border-slate-800/40 rounded-2xl hover:border-primary/30 transition-all group">
|
||||
<div key={user.id} className="flex items-center justify-between p-3 bg-background/40 border border-slate-800/40 rounded-2xl hover:border-primary/30 transition-all group">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={cn(
|
||||
"w-9 h-9 rounded-xl flex items-center justify-center transition-colors shadow-inner shrink-0",
|
||||
@@ -85,10 +85,10 @@ export default function IdentityManager({
|
||||
|
||||
{/* Edit User Modal */}
|
||||
{editingUser && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-background/80 animate-in fade-in duration-200">
|
||||
<div className="bg-surface border border-slate-800 rounded-[2.5rem] p-8 w-full max-w-md shadow-2xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-white tracking-tight">Edit Identity</h3>
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-background/80 animate-in fade-in duration-200">
|
||||
<div className="bg-surface border border-slate-800 rounded-[2.5rem] p-6 w-full max-w-md shadow-2xl">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-xl font-normal text-white tracking-tight">Edit Identity</h3>
|
||||
<button
|
||||
onClick={() => setEditingUser(null)}
|
||||
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 text-muted hover:text-white transition-colors focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none rounded"
|
||||
@@ -98,41 +98,41 @@ export default function IdentityManager({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary tracking-tight ml-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-normal text-secondary tracking-tight ml-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editUserForm.username}
|
||||
onChange={(e) => setEditUserForm({ ...editUserForm, username: e.target.value })}
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-2xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-white outline-none focus:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-0 transition-all font-mono"
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-2xl py-1.5 px-4 text-sm font-normal text-white outline-none focus:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-0 transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary tracking-tight ml-1">New Password (Leave Empty To Keep)</label>
|
||||
<input
|
||||
type="password"
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-normal text-secondary tracking-tight ml-1">New Password (Leave Empty To Keep)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={editUserForm.password}
|
||||
onChange={(e) => setEditUserForm({ ...editUserForm, password: e.target.value })}
|
||||
placeholder="********"
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-2xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-white outline-none focus:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-0 transition-all font-mono"
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-2xl py-1.5 px-4 text-sm font-normal text-white outline-none focus:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-0 transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary tracking-tight ml-1">Role</label>
|
||||
<select
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-normal text-secondary tracking-tight ml-1">Role</label>
|
||||
<select
|
||||
value={editUserForm.role}
|
||||
onChange={(e) => setEditUserForm({ ...editUserForm, role: e.target.value })}
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-2xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-white outline-none focus:border-primary/50 transition-all appearance-none"
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-2xl py-1.5 px-4 text-sm font-normal text-white outline-none focus:border-primary/50 transition-all appearance-none"
|
||||
>
|
||||
<option value="user">Standard User</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<button
|
||||
onClick={onUpdateUserSubmit}
|
||||
className="w-full bg-primary hover:bg-primary/80 text-white rounded-2xl py-4 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black transition-all active:scale-95 shadow-xl shadow-primary/20 tracking-tight mt-4 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900 focus-visible:ring-primary focus-visible:outline-none"
|
||||
className="w-full bg-primary hover:bg-primary/80 text-white rounded-2xl py-2.5 text-sm font-normal transition-all active:scale-95 shadow-xl shadow-primary/20 tracking-tight mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900 focus-visible:ring-primary focus-visible:outline-none"
|
||||
>
|
||||
Apply Changes
|
||||
</button>
|
||||
|
||||
@@ -18,13 +18,13 @@ export default function LdapManager({
|
||||
onUpdateLdap
|
||||
}: LdapManagerProps) {
|
||||
return (
|
||||
<div className="bg-surface/50 border border-slate-800/50 rounded-[2.5rem] p-5 md:p-8 shadow-2xl space-y-6 transition-all group/ldap">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-surface/50 border border-slate-800/50 rounded-[2.5rem] p-4 md:p-6 shadow-2xl space-y-3 transition-all group/ldap">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary border border-primary/20">
|
||||
<Globe size={20} />
|
||||
</div>
|
||||
<h2 className="text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-black text-white tracking-tight">Enterprise LDAP</h2>
|
||||
<h2 className="text-xl font-normal text-white tracking-tight">Enterprise LDAP</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,59 +57,59 @@ export default function LdapManager({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary tracking-tight ml-1">LDAP URI</label>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-normal text-secondary tracking-tight ml-1">LDAP URI</label>
|
||||
<div className="relative">
|
||||
<Server className="absolute left-3.5 top-1/2 -translate-y-1/2 text-muted" size={14} />
|
||||
<input
|
||||
type="text"
|
||||
<Server className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ldap://host:389"
|
||||
value={ldapConfig.server_uri}
|
||||
onChange={(e) => setLdapConfig({...ldapConfig, server_uri: e.target.value})}
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-2xl py-2 lg:py-3 xl:py-4.5 pl-10 pr-4 text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-white outline-none focus:border-primary/50 transition-all font-mono"
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-2xl py-1.5 pl-9 pr-4 text-xs font-normal text-white outline-none focus:border-primary/50 transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary tracking-tight ml-1">Context DN</label>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-normal text-secondary tracking-tight ml-1">Context DN</label>
|
||||
<div className="relative">
|
||||
<Shield className="absolute left-3.5 top-1/2 -translate-y-1/2 text-muted" size={14} />
|
||||
<input
|
||||
type="text"
|
||||
<Shield className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="dc=example,dc=com"
|
||||
value={ldapConfig.base_dn}
|
||||
onChange={(e) => setLdapConfig({...ldapConfig, base_dn: e.target.value})}
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-2xl py-2 lg:py-3 xl:py-4.5 pl-10 pr-4 text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-white outline-none focus:border-primary/50 transition-all font-mono"
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-2xl py-1.5 pl-9 pr-4 text-xs font-normal text-white outline-none focus:border-primary/50 transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary tracking-tight ml-1">User DN Template</label>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-normal text-secondary tracking-tight ml-1">User DN Template</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 text-muted" size={14} />
|
||||
<input
|
||||
type="text"
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="uid={username},ou=people..."
|
||||
value={ldapConfig.user_template}
|
||||
onChange={(e) => setLdapConfig({...ldapConfig, user_template: e.target.value})}
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-2xl py-2 lg:py-3 xl:py-4.5 pl-10 pr-4 text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-white outline-none focus:border-primary/50 transition-all font-mono"
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-2xl py-1.5 pl-9 pr-4 text-xs font-normal text-white outline-none focus:border-primary/50 transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-bold text-secondary tracking-tight ml-1">Groups Base DN</label>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-normal text-secondary tracking-tight ml-1">Groups Base DN</label>
|
||||
<div className="relative">
|
||||
<Layers className="absolute left-3.5 top-1/2 -translate-y-1/2 text-muted" size={14} />
|
||||
<input
|
||||
type="text"
|
||||
<Layers className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ou=groups"
|
||||
value={ldapConfig.groups_dn}
|
||||
onChange={(e) => setLdapConfig({...ldapConfig, groups_dn: e.target.value})}
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-2xl py-2 lg:py-3 xl:py-4.5 pl-10 pr-4 text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-bold text-white outline-none focus:border-primary/50 transition-all font-mono"
|
||||
className="w-full bg-background/80 border border-slate-800 rounded-2xl py-1.5 pl-9 pr-4 text-xs font-normal text-white outline-none focus:border-primary/50 transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,18 +144,18 @@ export default function LdapManager({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
<div className="flex gap-2 pt-0">
|
||||
<button
|
||||
onClick={onTestLdap}
|
||||
disabled={testingLdap}
|
||||
className="flex-1 bg-primary hover:bg-primary text-white rounded-xl py-3 lg:py-4 xl:py-5 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black shadow-xl shadow-primary/10 transition-all active:scale-95 disabled:opacity-50 flex items-center justify-center gap-2 border border-primary/30"
|
||||
className="flex-1 bg-primary hover:bg-primary text-white rounded-xl py-1.5 text-sm font-normal shadow-xl shadow-primary/10 transition-all active:scale-95 disabled:opacity-50 flex items-center justify-center gap-2 border border-primary/30"
|
||||
>
|
||||
{testingLdap ? <RotateCcw size={14} className="animate-spin" /> : <Wifi size={14} />}
|
||||
Test Connection
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
onClick={onUpdateLdap}
|
||||
className="flex-[2] bg-primary hover:bg-primary text-white rounded-xl py-3 lg:py-4 xl:py-5 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-black shadow-xl shadow-primary/10 transition-all active:scale-95 flex items-center justify-center gap-2 border border-primary/30 tracking-tight"
|
||||
className="flex-[2] bg-primary hover:bg-primary text-white rounded-xl py-2 text-sm font-normal shadow-xl shadow-primary/10 transition-all active:scale-95 flex items-center justify-center gap-2 border border-primary/30 tracking-tight"
|
||||
>
|
||||
<Shield size={14} /> Save LDAP Policy
|
||||
</button>
|
||||
|
||||
124
frontend/components/inventory/QuantityAdjustmentModal.tsx
Normal file
124
frontend/components/inventory/QuantityAdjustmentModal.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import QuantityDisplay from './QuantityDisplay';
|
||||
import axios from 'axios';
|
||||
import { Item } from '@/lib/db';
|
||||
|
||||
interface QuantityAdjustmentModalProps {
|
||||
item: Item | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function QuantityAdjustmentModal({
|
||||
item,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: QuantityAdjustmentModalProps) {
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
// Handle closing
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setIsClosing(false);
|
||||
setSuccessMessage(null);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Handle quantity change via QuantityDisplay
|
||||
const handleQuantityChange = async (newQuantity: number) => {
|
||||
if (!item) return;
|
||||
|
||||
try {
|
||||
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8906';
|
||||
await axios.patch(`${backendUrl}/items/${item.id}`, {
|
||||
quantity: newQuantity,
|
||||
});
|
||||
|
||||
setSuccessMessage(`Quantity updated to ${newQuantity}`);
|
||||
setTimeout(() => {
|
||||
setSuccessMessage(null);
|
||||
handleClose();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to update quantity';
|
||||
console.error('Quantity update error:', errorMsg);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen || !item) return null;
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 z-50 bg-black/50 flex items-center justify-center transition-opacity ${isClosing ? 'opacity-0' : 'opacity-100'}`}>
|
||||
<div className={`bg-slate-950 rounded-lg shadow-lg w-full max-w-md mx-4 transition-transform ${isClosing ? 'scale-95' : 'scale-100'}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-800">
|
||||
<h2 className="text-lg font-normal">Adjust quantity</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
aria-label="Close quantity adjustment modal"
|
||||
className="p-2 hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} className="text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Item Details */}
|
||||
<div>
|
||||
<h3 className="text-2xl font-normal text-slate-100 mb-2">
|
||||
{item.name}
|
||||
</h3>
|
||||
<div className="text-sm text-slate-500 space-y-1">
|
||||
{item.part_number && (
|
||||
<div>Part Number: {item.part_number}</div>
|
||||
)}
|
||||
{item.barcode && (
|
||||
<div>Barcode: {item.barcode}</div>
|
||||
)}
|
||||
{item.category && (
|
||||
<div>Category: {item.category}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
{successMessage && (
|
||||
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg text-green-500 text-sm">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quantity Adjustment */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-400 mb-3 block">
|
||||
Current quantity
|
||||
</label>
|
||||
<QuantityDisplay
|
||||
itemId={String(item.id)}
|
||||
currentQuantity={item.quantity}
|
||||
onQuantityChange={handleQuantityChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="flex-1 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-100 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
frontend/components/inventory/QuantityDisplay.tsx
Normal file
131
frontend/components/inventory/QuantityDisplay.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Plus, Minus } from 'lucide-react';
|
||||
|
||||
interface QuantityDisplayProps {
|
||||
itemId: string;
|
||||
currentQuantity: number;
|
||||
onQuantityChange: (newQty: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function QuantityDisplay({
|
||||
itemId,
|
||||
currentQuantity,
|
||||
onQuantityChange,
|
||||
}: QuantityDisplayProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [localQuantity, setLocalQuantity] = useState(currentQuantity);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalQuantity(currentQuantity);
|
||||
}, [currentQuantity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleTapToEdit = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleIncrement = () => {
|
||||
setLocalQuantity(prev => Math.max(0, prev + 1));
|
||||
};
|
||||
|
||||
const handleDecrement = () => {
|
||||
setLocalQuantity(prev => Math.max(0, prev - 1));
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
if (value === '' || /^\d+$/.test(value)) {
|
||||
setLocalQuantity(value === '' ? 0 : parseInt(value, 10));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (localQuantity === currentQuantity) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await onQuantityChange(localQuantity);
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
setLocalQuantity(currentQuantity);
|
||||
setIsEditing(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setLocalQuantity(currentQuantity);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCommit();
|
||||
} else if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 bg-slate-900/50 rounded-lg px-3 py-2">
|
||||
<button
|
||||
onClick={handleDecrement}
|
||||
disabled={isLoading}
|
||||
aria-label="Decrease quantity"
|
||||
className="p-1 hover:bg-slate-800 rounded text-secondary hover:text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={localQuantity}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleCommit}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isLoading}
|
||||
aria-label="Quantity"
|
||||
className="w-12 text-center bg-transparent text-primary font-medium focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleIncrement}
|
||||
disabled={isLoading}
|
||||
aria-label="Increase quantity"
|
||||
className="p-1 hover:bg-slate-800 rounded text-secondary hover:text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleTapToEdit}
|
||||
disabled={isLoading}
|
||||
aria-label={`Quantity: ${currentQuantity}. Tap to edit`}
|
||||
className="text-lg font-medium text-primary hover:bg-slate-900/30 px-3 py-2 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{currentQuantity}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
201
frontend/components/inventory/SearchModal.tsx
Normal file
201
frontend/components/inventory/SearchModal.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { X, Search } from 'lucide-react';
|
||||
import { Item } from '@/lib/db';
|
||||
import { axiosInstance } from '@/lib/api';
|
||||
|
||||
interface SearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelectItem: (item: Item) => void;
|
||||
}
|
||||
|
||||
export default function SearchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelectItem,
|
||||
}: SearchModalProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<Item[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Auto-focus input when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Debounced search
|
||||
const performSearch = useCallback(async (searchQuery: string) => {
|
||||
if (searchQuery.length < 1) {
|
||||
setResults([]);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await axiosInstance.get('/items/search', {
|
||||
params: { q: searchQuery }
|
||||
});
|
||||
|
||||
setResults(response.data || []);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Search failed';
|
||||
setError(errorMsg);
|
||||
setResults([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle input change with debouncing (300ms)
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newQuery = e.target.value;
|
||||
setQuery(newQuery);
|
||||
|
||||
// Clear previous debounce timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
// Set new debounce timer
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
performSearch(newQuery);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Handle Escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Handle item selection
|
||||
const handleSelectItem = (item: Item) => {
|
||||
onSelectItem(item);
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Cleanup debounce on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-start justify-center pt-20">
|
||||
<div className="bg-slate-950 rounded-lg shadow-lg w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-800">
|
||||
<h2 className="text-xl font-normal">Search inventory</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close search modal"
|
||||
className="p-2 hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} className="text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="p-4 border-b border-slate-800">
|
||||
<div className="relative">
|
||||
<Search size={18} className="absolute left-3 top-3 text-slate-500 pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search by name, PN, barcode..."
|
||||
value={query}
|
||||
onChange={handleInputChange}
|
||||
aria-label="Search items"
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-lg pl-10 pr-4 py-2 text-slate-100 placeholder-slate-600 focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error && (
|
||||
<div className="p-4 text-rose-500 bg-rose-500/10 border border-rose-500/20 m-4 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-slate-400">
|
||||
<div className="inline-block animate-spin rounded-full h-6 w-6 border border-slate-600 border-t-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && results.length === 0 && query.length > 0 && (
|
||||
<div className="p-4 text-slate-500 text-center">
|
||||
No items found matching "{query}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && results.length === 0 && query.length === 0 && (
|
||||
<div className="p-4 text-slate-500 text-center">
|
||||
Start typing to search
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && results.length > 0 && (
|
||||
<div className="divide-y divide-slate-800">
|
||||
{results.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelectItem(item)}
|
||||
className="w-full p-4 text-left hover:bg-slate-900 transition-colors focus:outline-none focus:bg-slate-900 focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<div className="flex justify-between items-start gap-2 mb-1">
|
||||
<h3 className="font-medium text-slate-100 flex-1">
|
||||
{item.name}
|
||||
</h3>
|
||||
<span className="text-primary font-medium text-sm whitespace-nowrap">
|
||||
Qty: {item.quantity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 space-y-1">
|
||||
{item.part_number && (
|
||||
<div>PN: {item.part_number}</div>
|
||||
)}
|
||||
{item.barcode && (
|
||||
<div>Barcode: {item.barcode}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user