mirror of
https://github.com/duongcamcute/tech-gadget-manager.git
synced 2026-03-03 00:27:01 +00:00
feat: 4 tính năng mới - filter vị trí, ảnh vị trí, toggle thumbnail, warranty stats
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Location" ADD COLUMN "image" TEXT;
|
||||
@@ -65,6 +65,7 @@ model Location {
|
||||
name String
|
||||
type String
|
||||
icon String?
|
||||
image String? // Base64 WebP ảnh minh họa vị trí
|
||||
parentId String?
|
||||
parent Location? @relation("LocationHierarchy", fields: [parentId], references: [id])
|
||||
children Location[] @relation("LocationHierarchy")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { QrCode, Package, Database, Search, LayoutDashboard, Tag, Wallet, Check, X, ArrowRightLeft, List, LayoutGrid, HelpCircle, Trash2, AlertTriangle, Shield, Clock } from "lucide-react";
|
||||
import { QrCode, Package, Database, Search, LayoutDashboard, Tag, Wallet, Check, X, ArrowRightLeft, List, LayoutGrid, HelpCircle, Trash2, AlertTriangle, Shield, Clock, Image as ImageIcon } from "lucide-react";
|
||||
import { Card, Button, Input, Select } from "@/components/ui/primitives";
|
||||
import { Checkbox } from "@/components/ui/Checkbox";
|
||||
import { ItemDetailDialog } from "./ItemDetailDialog";
|
||||
@@ -44,6 +44,7 @@ function getStatusColorClasses(status: string) {
|
||||
export default function InventoryManager({ initialItems, locations }: { initialItems: any[], locations: any[] }) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [showThumbnails, setShowThumbnails] = useState(false);
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
|
||||
// Complex Filter State
|
||||
@@ -52,6 +53,7 @@ export default function InventoryManager({ initialItems, locations }: { initialI
|
||||
status: 'all',
|
||||
brand: 'all',
|
||||
color: 'all',
|
||||
location: 'all',
|
||||
power: 'all',
|
||||
length: 'all',
|
||||
capacity: 'all'
|
||||
@@ -122,6 +124,9 @@ export default function InventoryManager({ initialItems, locations }: { initialI
|
||||
|
||||
const matchesColor = activeFilters.color === 'all' || item.color === activeFilters.color;
|
||||
|
||||
// Location filter
|
||||
const matchesLocation = activeFilters.location === 'all' || item.locationId === activeFilters.location;
|
||||
|
||||
// Spec checking
|
||||
let matchesSpecs = true;
|
||||
try {
|
||||
@@ -131,7 +136,7 @@ export default function InventoryManager({ initialItems, locations }: { initialI
|
||||
if (activeFilters.capacity !== 'all' && s.capacity !== activeFilters.capacity) matchesSpecs = false;
|
||||
} catch { matchesSpecs = true; }
|
||||
|
||||
return matchesSearch && matchesCategory && matchesBrand && matchesStatus && matchesColor && matchesSpecs;
|
||||
return matchesSearch && matchesCategory && matchesBrand && matchesStatus && matchesColor && matchesLocation && matchesSpecs;
|
||||
});
|
||||
}, [initialItems, searchQuery, activeFilters]);
|
||||
|
||||
@@ -144,11 +149,11 @@ export default function InventoryManager({ initialItems, locations }: { initialI
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// Warranty stats - items expiring within 30 days
|
||||
const warrantyExpiringItems = initialItems.filter(i => {
|
||||
// Warranty stats - chỉ lấy items SẮP HẾT (không bao gồm đã hết)
|
||||
const warrantyExpiringSoonItems = initialItems.filter(i => {
|
||||
if (i.warrantyEnd) {
|
||||
const info = checkWarranty(i.warrantyEnd);
|
||||
return info.isExpiringSoon || info.isExpired;
|
||||
return info.isExpiringSoon && !info.isExpired; // Chỉ sắp hết, chưa hết
|
||||
}
|
||||
return false;
|
||||
});
|
||||
@@ -159,7 +164,7 @@ export default function InventoryManager({ initialItems, locations }: { initialI
|
||||
available: initialItems.filter(i => i.status === 'Available').length,
|
||||
unsorted: initialItems.filter(i => !i.location || !i.locationId).length,
|
||||
overdue: overdueItems.length,
|
||||
warrantyExpiring: warrantyExpiringItems.length
|
||||
warrantyExpiring: warrantyExpiringSoonItems.length // Chỉ sắp hết
|
||||
};
|
||||
}, [initialItems]);
|
||||
|
||||
@@ -329,13 +334,17 @@ export default function InventoryManager({ initialItems, locations }: { initialI
|
||||
<option value="all">Tất cả màu</option>
|
||||
{filterOptions.colors.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</Select>
|
||||
<Select value={activeFilters.location} onChange={(e: any) => setActiveFilters(prev => ({ ...prev, location: e.target.value }))} className="w-full bg-white dark:bg-gray-800 dark:text-gray-100 rounded-xl text-xs h-9">
|
||||
<option value="all">Tất cả vị trí</option>
|
||||
{locations.map((l: any) => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||
</Select>
|
||||
|
||||
{/* Dynamic Specs Filters */}
|
||||
{showPower && <Select value={activeFilters.power} onChange={(e: any) => setActiveFilters(prev => ({ ...prev, power: e.target.value }))} className="bg-orange-50 border-orange-200 rounded-xl text-xs h-9"><option value="all">Công suất...</option>{filterOptions.powers.map(p => <option key={p} value={p}>{p}</option>)}</Select>}
|
||||
{showLength && <Select value={activeFilters.length} onChange={(e: any) => setActiveFilters(prev => ({ ...prev, length: e.target.value }))} className="bg-emerald-50 border-emerald-200 rounded-xl text-xs h-9"><option value="all">Độ dài...</option>{filterOptions.lengths.map(p => <option key={p} value={p}>{p}</option>)}</Select>}
|
||||
{showCapacity && <Select value={activeFilters.capacity} onChange={(e: any) => setActiveFilters(prev => ({ ...prev, capacity: e.target.value }))} className="bg-purple-50 border-purple-200 rounded-xl text-xs h-9"><option value="all">Dung lượng...</option>{filterOptions.capacities.map(p => <option key={p} value={p}>{p}</option>)}</Select>}
|
||||
|
||||
<Button variant="ghost" onClick={() => setActiveFilters({ category: 'all', status: 'all', brand: 'all', color: 'all', power: 'all', length: 'all', capacity: 'all' })} className="text-red-500 hover:bg-red-50 hover:text-red-600 h-9 rounded-xl px-3 border border-transparent hover:border-red-100">
|
||||
<Button variant="ghost" onClick={() => setActiveFilters({ category: 'all', status: 'all', brand: 'all', color: 'all', location: 'all', power: 'all', length: 'all', capacity: 'all' })} className="text-red-500 hover:bg-red-50 hover:text-red-600 h-9 rounded-xl px-3 border border-transparent hover:border-red-100">
|
||||
Xóa bộ lọc
|
||||
</Button>
|
||||
</div>
|
||||
@@ -349,6 +358,14 @@ export default function InventoryManager({ initialItems, locations }: { initialI
|
||||
<div className="flex bg-gray-100 dark:bg-gray-800 p-1 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<button onClick={() => setViewMode('grid')} className={`p-1.5 rounded-md transition-all ${viewMode === 'grid' ? 'bg-white dark:bg-gray-700 shadow-sm text-primary-600 dark:text-primary-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}><LayoutGrid className="w-4 h-4" /></button>
|
||||
<button onClick={() => setViewMode('list')} className={`p-1.5 rounded-md transition-all ${viewMode === 'list' ? 'bg-white dark:bg-gray-700 shadow-sm text-primary-600 dark:text-primary-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}><List className="w-4 h-4" /></button>
|
||||
<div className="w-px bg-gray-300 dark:bg-gray-600 mx-1"></div>
|
||||
<button
|
||||
onClick={() => setShowThumbnails(!showThumbnails)}
|
||||
className={`p-1.5 rounded-md transition-all ${showThumbnails ? 'bg-white dark:bg-gray-700 shadow-sm text-primary-600 dark:text-primary-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||
title="Hiển thị ảnh thumbnail"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center justify-between sm:justify-end">
|
||||
@@ -448,17 +465,25 @@ export default function InventoryManager({ initialItems, locations }: { initialI
|
||||
{/* QR */}
|
||||
<Link href={`/items/${item.id}/qr`} className="absolute top-2 right-2 z-20 p-1.5 text-gray-300 hover:text-primary-600 hover:bg-primary-50 rounded-lg" title="Xem mã QR"><QrCode className="h-4 w-4" /></Link>
|
||||
|
||||
{/* Icon Column */}
|
||||
{/* Icon Column - Show thumbnail if enabled and image exists */}
|
||||
<div
|
||||
className={`w-28 shrink-0 flex flex-col items-center justify-center relative cursor-pointer group-hover/image overflow-hidden transition-colors duration-300 ${!item.color ? bg : ''} ${isWhite ? 'bg-white border-r border-gray-50' : ''}`}
|
||||
style={!isWhite && item.color ? { backgroundColor: `${getColorHex(item.color)}15` } : {}}
|
||||
className={`w-28 shrink-0 flex flex-col items-center justify-center relative cursor-pointer group-hover/image overflow-hidden transition-colors duration-300 ${!item.color && !showThumbnails ? bg : ''} ${isWhite && !showThumbnails ? 'bg-white border-r border-gray-50' : ''}`}
|
||||
style={!isWhite && item.color && !showThumbnails ? { backgroundColor: `${getColorHex(item.color)}15` } : {}}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
>
|
||||
<div className="transform transition-transform duration-500 group-hover:scale-110 mb-4">
|
||||
<Icon
|
||||
className={`h-12 w-12 ${!item.color ? color : ''}`}
|
||||
style={item.color ? { color: isWhite ? '#fbbf24' : getColorHex(item.color) } : {}}
|
||||
/>
|
||||
{showThumbnails && item.image ? (
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className="w-20 h-20 object-cover rounded-lg shadow-md"
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
className={`h-12 w-12 ${!item.color ? color : ''}`}
|
||||
style={item.color ? { color: isWhite ? '#fbbf24' : getColorHex(item.color) } : {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Pill in Card */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Folder, Box, Plus, User, Trash2, ChevronRight, ChevronDown, MapPin, Package, RefreshCw, Pencil } from "lucide-react";
|
||||
import { Folder, Box, Plus, User, Trash2, ChevronRight, ChevronDown, MapPin, Package, RefreshCw, Pencil, Image as ImageIcon, X } from "lucide-react";
|
||||
import { createLocation, deleteLocation, getLocationItems, updateLocation } from "@/features/location/actions";
|
||||
import { ITEM_ICONS, LOCATION_ICONS, LOCATION_ICON_GROUPS } from "@/lib/constants/options";
|
||||
import { IconSelect } from "@/components/ui/IconSelect";
|
||||
@@ -12,6 +12,7 @@ import { useRouter } from "next/navigation";
|
||||
import { ItemDetailDialog } from "@/features/inventory/ItemDetailDialog";
|
||||
import { getItem } from "@/app/actions";
|
||||
import { LocationDetailView } from "@/features/locations/LocationDetailView";
|
||||
import { optimizeImage } from "@/lib/imageUtils";
|
||||
|
||||
interface LocationNode {
|
||||
id: string;
|
||||
@@ -58,10 +59,28 @@ export function LocationManager({ initialLocations }: { initialLocations: Locati
|
||||
const [newLocName, setNewLocName] = useState("");
|
||||
const [newLocType, setNewLocType] = useState("Fixed");
|
||||
const [newLocIcon, setNewLocIcon] = useState("");
|
||||
const [newLocImage, setNewLocImage] = useState<string | null>(null);
|
||||
const [newLocParentId, setNewLocParentId] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
// Handle image upload with optimization
|
||||
const handleImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
try {
|
||||
const optimized = await optimizeImage(file);
|
||||
setNewLocImage(optimized);
|
||||
} catch (err) {
|
||||
console.error('Error optimizing image:', err);
|
||||
// Fallback to original
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => setNewLocImage(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Find selected node in tree to get details
|
||||
const findNode = (id: string, nodes: LocationNode[]): LocationNode | null => {
|
||||
for (const node of nodes) {
|
||||
@@ -97,6 +116,7 @@ export function LocationManager({ initialLocations }: { initialLocations: Locati
|
||||
name: newLocName,
|
||||
type: newLocType,
|
||||
icon: newLocIcon,
|
||||
image: newLocImage,
|
||||
parentId: newLocParentId || selectedLocationId // Use explicit parent or current selected
|
||||
});
|
||||
|
||||
@@ -104,6 +124,7 @@ export function LocationManager({ initialLocations }: { initialLocations: Locati
|
||||
toast("Đã tạo vị trí mới thành công", "success");
|
||||
setNewLocName("");
|
||||
setNewLocIcon("");
|
||||
setNewLocImage(null);
|
||||
if (!selectedLocationId) setIsCreating(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
@@ -117,12 +138,14 @@ export function LocationManager({ initialLocations }: { initialLocations: Locati
|
||||
name: newLocName,
|
||||
type: newLocType,
|
||||
icon: newLocIcon,
|
||||
image: newLocImage,
|
||||
parentId: newLocParentId
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
toast("Đã cập nhật vị trí", "success");
|
||||
setIsEditing(false);
|
||||
setNewLocImage(null);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast(res.error || "Lỗi cập nhật", "error");
|
||||
@@ -323,6 +346,40 @@ export function LocationManager({ initialLocations }: { initialLocations: Locati
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
{/* Upload ảnh vị trí */}
|
||||
<div className="col-span-2">
|
||||
<Label>Ảnh minh họa (Tùy chọn)</Label>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{newLocImage ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={newLocImage}
|
||||
alt="Preview"
|
||||
className="w-20 h-20 object-cover rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewLocImage(null)}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-0.5 hover:bg-red-600"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex flex-col items-center justify-center w-20 h-20 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer hover:border-primary-400 dark:hover:border-primary-500 transition-colors">
|
||||
<ImageIcon className="w-6 h-6 text-gray-400" />
|
||||
<span className="text-[10px] text-gray-400 mt-1">Upload</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">Ảnh sẽ được nén tự động</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button onClick={isEditing ? handleUpdate : handleCreate} className="flex-1 bg-primary-500 dark:bg-primary-600 hover:bg-primary-600 dark:hover:bg-primary-700 text-white font-bold shadow-lg shadow-primary-500/20">
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function createLocation(data: { name: string; type: string; parentId?: string | null; icon?: string }) {
|
||||
export async function createLocation(data: { name: string; type: string; parentId?: string | null; icon?: string; image?: string | null }) {
|
||||
try {
|
||||
const location = await prisma.location.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
icon: data.icon,
|
||||
image: data.image || null,
|
||||
parentId: data.parentId || null,
|
||||
},
|
||||
});
|
||||
@@ -21,7 +22,7 @@ export async function createLocation(data: { name: string; type: string; parentI
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLocation(id: string, data: { name: string; type: string; parentId?: string | null; icon?: string }) {
|
||||
export async function updateLocation(id: string, data: { name: string; type: string; parentId?: string | null; icon?: string; image?: string | null }) {
|
||||
try {
|
||||
// Prevent setting parent to itself
|
||||
if (data.parentId === id) {
|
||||
@@ -34,6 +35,7 @@ export async function updateLocation(id: string, data: { name: string; type: str
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
icon: data.icon,
|
||||
image: data.image,
|
||||
parentId: data.parentId || null,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user