feat: 4 tính năng mới - filter vị trí, ảnh vị trí, toggle thumbnail, warranty stats

This commit is contained in:
Dương Cầm
2026-02-04 19:30:19 +07:00
parent 30af9bd49e
commit 19cf5dc8be
5 changed files with 104 additions and 17 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Location" ADD COLUMN "image" TEXT;

View File

@@ -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")

View File

@@ -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 */}

View File

@@ -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">

View File

@@ -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,
},
});