mirror of
https://github.com/duongcamcute/tech-gadget-manager.git
synced 2026-03-03 02:27:01 +00:00
feat: expand badge system & fix location image display
- Add centralized BADGE_ICONS_MAP and BADGE_COLORS_MAP to options.ts - Implement dynamic badge UI in SmartAddForm (Add Item) - Update ItemDetailDialog (Edit Item) to use centralized config - Update InventoryManager to support new badge icons - Display location image in detail header - Fix capacity badge missing icon/color - Fix location edit not loading existing image
This commit is contained in:
1686
public/database.json
Normal file
1686
public/database.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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, Image as ImageIcon } from "lucide-react";
|
||||
import { QrCode, Package, Database, Search, LayoutDashboard, Tag, Wallet, Check, X, ArrowRightLeft, List, LayoutGrid, HelpCircle, Trash2, AlertTriangle, Shield, 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";
|
||||
@@ -17,6 +17,10 @@ import { bulkMoveItems, bulkDeleteItems } from "@/app/actions";
|
||||
import { bulkLendItems, lendItem, returnItem } from "@/features/lending/actions";
|
||||
import { checkOverdue, getUrgencyLevel, formatOverdueStatus } from "@/lib/utils/overdueChecker";
|
||||
import { checkWarranty, getWarrantyUrgency, formatWarrantyStatus } from "@/lib/utils/warrantyChecker";
|
||||
import { Tag as TagIcon } from "lucide-react";
|
||||
import { BADGE_ICONS_MAP, BADGE_COLORS_MAP } from "@/lib/constants/options";
|
||||
|
||||
|
||||
|
||||
function getItemIconData(item: any) {
|
||||
const type = item.category || item.type || 'Other';
|
||||
@@ -570,16 +574,55 @@ export default function InventoryManager({ initialItems, locations }: { initialI
|
||||
<h3 className="font-bold text-sm text-gray-900 dark:text-gray-100 line-clamp-2 leading-tight mb-2" title={item.name}>{item.name}</h3>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{/* Check displayBadges array - if exists only show selected, else show all */}
|
||||
{/* Display Badges (Dynamic) */}
|
||||
{(() => {
|
||||
const badges = specs.displayBadges || ['power', 'length', 'capacity']; // default show all
|
||||
let activeBadges: any[] = specs.displayBadges || [];
|
||||
|
||||
// Migration for old string[] format
|
||||
if (activeBadges.length > 0 && typeof activeBadges[0] === 'string') {
|
||||
const defaultMap: any = {
|
||||
power: { icon: 'zap', color: 'orange' },
|
||||
length: { icon: 'tag', color: 'emerald' },
|
||||
capacity: { icon: 'battery', color: 'purple' },
|
||||
interface: { icon: 'tag', color: 'blue' },
|
||||
bandwidth: { icon: 'zap', color: 'cyan' }
|
||||
};
|
||||
activeBadges = activeBadges.map((key: string) => ({
|
||||
key,
|
||||
icon: defaultMap[key]?.icon || 'tag',
|
||||
color: defaultMap[key]?.color || 'blue'
|
||||
}));
|
||||
}
|
||||
|
||||
// If empty and not customized, show defaults if values exist (Legacy fallback)
|
||||
if (activeBadges.length === 0 && !specs.displayBadges) {
|
||||
if (specs.power) activeBadges.push({ key: 'power', icon: 'zap', color: 'orange' });
|
||||
if (specs.length) activeBadges.push({ key: 'length', icon: 'tag', color: 'emerald' });
|
||||
if (specs.capacity) activeBadges.push({ key: 'capacity', icon: 'battery', color: 'purple' });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{badges.includes('power') && specs.power && <span className="bg-orange-50 px-1.5 py-0.5 rounded text-[10px] text-orange-700 border border-orange-100 font-medium whitespace-nowrap" title="Công suất">⚡ {specs.power}</span>}
|
||||
{badges.includes('length') && specs.length && <span className="bg-emerald-50 px-1.5 py-0.5 rounded text-[10px] text-emerald-700 border border-emerald-100 font-medium whitespace-nowrap" title="Độ dài">📏 {specs.length}</span>}
|
||||
{badges.includes('capacity') && specs.capacity && <span className="bg-purple-50 px-1.5 py-0.5 rounded text-[10px] text-purple-700 border border-purple-100 font-medium whitespace-nowrap" title="Dung lượng">🔋 {specs.capacity}</span>}
|
||||
{badges.includes('interface') && specs.interface && <span className="bg-blue-50 px-1.5 py-0.5 rounded text-[10px] text-blue-700 border border-blue-100 font-medium whitespace-nowrap" title="Kết nối">🔌 {specs.interface}</span>}
|
||||
{badges.includes('bandwidth') && specs.bandwidth && <span className="bg-cyan-50 px-1.5 py-0.5 rounded text-[10px] text-cyan-700 border border-cyan-100 font-medium whitespace-nowrap" title="Tốc độ">⚡ {specs.bandwidth}</span>}
|
||||
{activeBadges.map((badge: any, idx: number) => {
|
||||
const Icon = BADGE_ICONS_MAP[badge.icon] || TagIcon;
|
||||
const colorClass = BADGE_COLORS_MAP[badge.color] || BADGE_COLORS_MAP['blue'];
|
||||
const value = item[badge.key] || specs[badge.key];
|
||||
|
||||
if (!value) return null; // Don't show empty badges
|
||||
|
||||
// Format value specially for dates or known fields if needed
|
||||
let displayValue = value;
|
||||
if (badge.key === 'warrantyEnd' && value) {
|
||||
displayValue = new Date(value).toLocaleDateString('vi-VN');
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={idx} className={`${colorClass} px-1.5 py-0.5 rounded text-[10px] border font-medium whitespace-nowrap flex items-center gap-1`} title={badge.key}>
|
||||
<Icon size={10} />
|
||||
<span className="max-w-[80px] truncate">{displayValue}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { AutoCompleteInput } from "@/components/ui/AutoCompleteInput";
|
||||
import { ColorPicker } from "@/components/ui/ColorPicker";
|
||||
import { IconSelect } from "@/components/ui/IconSelect";
|
||||
import { getColorHex } from "@/lib/utils/colors";
|
||||
import { ITEM_TYPES, LOCATION_ICONS, ITEM_ICONS } from "@/lib/constants/options";
|
||||
import { ITEM_TYPES, LOCATION_ICONS, ITEM_ICONS, BADGE_ICONS_MAP, BADGE_COLORS_MAP } from "@/lib/constants/options";
|
||||
import { TECH_SUGGESTIONS } from "@/lib/constants";
|
||||
import { optimizeImage, formatBytes, getBase64Size } from "@/lib/imageUtils";
|
||||
|
||||
@@ -337,12 +337,14 @@ function ViewMode({ item, setMode, onDelete }: { item: any, setMode: (m: "EDIT")
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.warrantyEnd && (
|
||||
<div className="border-l-2 border-emerald-100 pl-3">
|
||||
<p className="text-[10px] text-gray-500 uppercase font-semibold mb-0.5">Bảo hành</p>
|
||||
<div className="border-l-2 border-emerald-100 pl-3">
|
||||
<p className="text-[10px] text-gray-500 uppercase font-semibold mb-0.5">Bảo hành</p>
|
||||
{item.warrantyEnd ? (
|
||||
<p className="font-medium text-green-700 dark:text-green-400 text-sm">{formatDateVN(item.warrantyEnd)}</p>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<p className="font-medium text-gray-400 text-sm italic">Không bảo hành</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.purchaseLocation && (
|
||||
<div className="border-l-2 border-emerald-100 pl-3">
|
||||
@@ -666,33 +668,68 @@ function EditMode({ item, locations, onCancel, onClose }: { item: any, locations
|
||||
<Label>Nơi mua</Label>
|
||||
<Input {...form.register("purchaseLocation")} placeholder="Cửa hàng, Shopee..." />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Hết hạn bảo hành</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input type="date" {...form.register("warrantyEnd")} className="flex-1" />
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 p-3 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<Label className="text-xs font-semibold text-gray-700 dark:text-gray-300">Hạn bảo hành</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="edit-no-warranty"
|
||||
checked={!form.watch("warrantyEnd") && warrantyMonths === ""}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
// Disable Warranty
|
||||
setWarrantyMonths("");
|
||||
form.setValue("warrantyEnd", null, { shouldDirty: true });
|
||||
} else {
|
||||
// Enable Warranty (Restore default)
|
||||
setWarrantyMonths("12");
|
||||
// Trigger update via effect or manual set if date exists
|
||||
const pDate = form.getValues("purchaseDate");
|
||||
if (pDate) {
|
||||
const d = new Date(pDate);
|
||||
d.setMonth(d.getMonth() + 12);
|
||||
form.setValue("warrantyEnd", d.toISOString().split('T')[0], { shouldDirty: true });
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="edit-no-warranty" className="text-xs font-medium text-gray-600 dark:text-gray-400 cursor-pointer select-none">Không bảo hành</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-1 items-center">
|
||||
<div className={`flex flex-col gap-2 transition-opacity duration-200 ${(!form.watch("warrantyEnd") && warrantyMonths === "") ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Tháng..."
|
||||
className="h-8 w-20 text-xs"
|
||||
value={warrantyMonths}
|
||||
onChange={(e) => setWarrantyMonths(e.target.value)}
|
||||
type="date"
|
||||
{...form.register("warrantyEnd")}
|
||||
className="h-9 bg-white dark:bg-gray-900"
|
||||
disabled={!form.watch("warrantyEnd") && warrantyMonths === ""}
|
||||
/>
|
||||
<Select
|
||||
className="h-8 flex-1 bg-white dark:bg-gray-800 text-xs"
|
||||
onChange={(e) => {
|
||||
if (e.target.value) setWarrantyMonths(e.target.value);
|
||||
}}
|
||||
value={""}
|
||||
>
|
||||
<option value="">+ Chọn nhanh...</option>
|
||||
<option value="6">6 Tháng</option>
|
||||
<option value="12">12 Tháng</option>
|
||||
<option value="18">18 Tháng</option>
|
||||
<option value="24">24 Tháng (2 Năm)</option>
|
||||
<option value="36">36 Tháng (3 Năm)</option>
|
||||
</Select>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Tháng..."
|
||||
className="h-9 w-20 text-xs bg-white dark:bg-gray-900"
|
||||
value={warrantyMonths}
|
||||
onChange={(e) => setWarrantyMonths(e.target.value)}
|
||||
disabled={!form.watch("warrantyEnd") && warrantyMonths === ""}
|
||||
/>
|
||||
<Select
|
||||
className="h-9 flex-1 bg-white dark:bg-gray-900 text-xs"
|
||||
onChange={(e) => {
|
||||
if (e.target.value) setWarrantyMonths(e.target.value);
|
||||
}}
|
||||
value={""}
|
||||
disabled={!form.watch("warrantyEnd") && warrantyMonths === ""}
|
||||
>
|
||||
<option value="">+ Chọn nhanh...</option>
|
||||
<option value="6">6 Tháng</option>
|
||||
<option value="12">12 Tháng</option>
|
||||
<option value="18">18 Tháng</option>
|
||||
<option value="24">24 Tháng (2 Năm)</option>
|
||||
<option value="36">36 Tháng (3 Năm)</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
@@ -754,58 +791,131 @@ function EditMode({ item, locations, onCancel, onClose }: { item: any, locations
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Badges Selection */}
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
Hiển thị trên Card
|
||||
<span className="text-[10px] bg-orange-100 text-orange-700 px-1.5 py-0.5 rounded-full font-medium">Badge</span>
|
||||
</Label>
|
||||
<p className="text-[10px] text-gray-500 -mt-1">Chọn thông số sẽ hiển thị dưới dạng badge trên card của thiết bị</p>
|
||||
<div className="bg-gradient-to-r from-orange-50 to-emerald-50 rounded-lg p-3 border border-orange-200/50">
|
||||
{(() => {
|
||||
const currentSpecs = form.watch("specs") || {};
|
||||
const displayBadges: string[] = currentSpecs.displayBadges || ['power', 'length', 'capacity'];
|
||||
const allBadgeOptions = [
|
||||
{ key: 'power', label: 'Công suất', icon: '⚡', color: 'bg-orange-100 border-orange-200 text-orange-700' },
|
||||
{ key: 'length', label: 'Độ dài', icon: '📏', color: 'bg-emerald-100 border-emerald-200 text-emerald-700' },
|
||||
{ key: 'capacity', label: 'Dung lượng', icon: '🔋', color: 'bg-purple-100 border-purple-200 text-purple-700' },
|
||||
{ key: 'interface', label: 'Kết nối', icon: '🔌', color: 'bg-blue-100 border-blue-200 text-blue-700' },
|
||||
{ key: 'bandwidth', label: 'Tốc độ', icon: '⚡', color: 'bg-cyan-100 border-cyan-200 text-cyan-700' },
|
||||
];
|
||||
|
||||
const toggleBadge = (key: string) => {
|
||||
let newBadges = [...displayBadges];
|
||||
if (newBadges.includes(key)) {
|
||||
newBadges = newBadges.filter(b => b !== key);
|
||||
} else {
|
||||
newBadges.push(key);
|
||||
}
|
||||
form.setValue("specs", { ...currentSpecs, displayBadges: newBadges }, { shouldDirty: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allBadgeOptions.map(opt => {
|
||||
const isChecked = displayBadges.includes(opt.key);
|
||||
const hasValue = currentSpecs[opt.key];
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
onClick={() => toggleBadge(opt.key)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border transition-all text-xs font-medium ${isChecked ? opt.color + ' shadow-sm' : 'bg-white border-gray-200 text-gray-400 hover:border-gray-300'} ${!hasValue ? 'opacity-50' : ''}`}
|
||||
title={hasValue ? `${opt.label}: ${currentSpecs[opt.key]}` : `Chưa có ${opt.label}`}
|
||||
>
|
||||
<span>{opt.icon}</span>
|
||||
<span>{opt.label}</span>
|
||||
{isChecked && <Check size={12} className="ml-1" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Display Badges Selection (Dynamic) */}
|
||||
<div className="col-span-2 space-y-3 bg-gray-50/50 dark:bg-gray-800/50 p-4 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<Label className="flex items-center gap-2 text-base font-semibold">
|
||||
Badge hiển thị
|
||||
<span className="text-[10px] bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full font-bold">Custom</span>
|
||||
</Label>
|
||||
<p className="text-xs text-gray-500 mb-3">Chọn thông tin để hiển thị nổi bật trên thẻ thiết bị.</p>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
// Default or existing badges
|
||||
const currentSpecs = form.watch("specs") || {};
|
||||
let currentBadges: any[] = currentSpecs.displayBadges || [];
|
||||
|
||||
// Migration: If string[], convert to objects
|
||||
if (currentBadges.length > 0 && typeof currentBadges[0] === 'string') {
|
||||
currentBadges = currentBadges.map((key: string) => ({ key, icon: 'tag', color: 'blue' }));
|
||||
}
|
||||
|
||||
const updateBadges = (newBadges: any[]) => {
|
||||
const newSpecs = { ...currentSpecs, displayBadges: newBadges };
|
||||
form.setValue("specs", newSpecs, { shouldDirty: true });
|
||||
};
|
||||
|
||||
// Get all available keys
|
||||
const specKeys = Object.keys(currentSpecs).filter(k => k !== 'displayBadges');
|
||||
const standardKeys = ['brand', 'type', 'purchaseLocation', 'warrantyEnd'];
|
||||
const allKeys = Array.from(new Set([...standardKeys, ...specKeys]));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* List of active badges */}
|
||||
<div className="space-y-2">
|
||||
{currentBadges.map((badge: any, idx: number) => (
|
||||
<div key={idx} className="flex gap-2 items-center bg-white dark:bg-gray-900 p-2 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm animate-in slide-in-from-left-2 fade-in duration-200">
|
||||
{/* Field Select */}
|
||||
<div className="w-1/3 min-w-[120px]">
|
||||
<div className="text-[10px] uppercase font-bold text-gray-400 mb-0.5">Trường thông tin</div>
|
||||
<div className="font-medium text-sm truncate">{badge.key}</div>
|
||||
</div>
|
||||
|
||||
{/* Icon Select (Dynamic) */}
|
||||
<div className="flex-1">
|
||||
<div className="text-[10px] uppercase font-bold text-gray-400 mb-0.5">Icon</div>
|
||||
<Select
|
||||
value={badge.icon || 'tag'}
|
||||
onChange={(e) => {
|
||||
const updated = [...currentBadges];
|
||||
updated[idx] = { ...updated[idx], icon: e.target.value };
|
||||
updateBadges(updated);
|
||||
}}
|
||||
className="h-8 text-xs w-full"
|
||||
>
|
||||
{Object.keys(BADGE_ICONS_MAP).map(k => (
|
||||
<option key={k} value={k}>{k}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Color Select (Dynamic) */}
|
||||
<div className="flex-1">
|
||||
<div className="text-[10px] uppercase font-bold text-gray-400 mb-0.5">Màu</div>
|
||||
<Select
|
||||
value={badge.color || 'blue'}
|
||||
onChange={(e) => {
|
||||
const updated = [...currentBadges];
|
||||
updated[idx] = { ...updated[idx], color: e.target.value };
|
||||
updateBadges(updated);
|
||||
}}
|
||||
className="h-8 text-xs w-full"
|
||||
>
|
||||
{Object.keys(BADGE_COLORS_MAP).map(k => (
|
||||
<option key={k} value={k}>{k}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Remove */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-red-500 hover:bg-red-50 hover:text-red-600 mt-4"
|
||||
onClick={() => {
|
||||
const updated = currentBadges.filter((_, i) => i !== idx);
|
||||
updateBadges(updated);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add New Badge */}
|
||||
<div className="flex gap-2 items-end pt-2 border-t border-gray-200/50 dark:border-gray-700/50">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs mb-1 block">Thêm badge mới</Label>
|
||||
<Select
|
||||
id="new-badge-select"
|
||||
className="h-9 text-sm"
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
const newBadge = {
|
||||
key: e.target.value,
|
||||
icon: 'tag',
|
||||
color: 'blue'
|
||||
};
|
||||
updateBadges([...currentBadges, newBadge]);
|
||||
e.target.value = ""; // Reset select
|
||||
}
|
||||
}}
|
||||
value=""
|
||||
>
|
||||
<option value="">+ Chọn trường thông tin...</option>
|
||||
{allKeys.filter(k => !currentBadges.some((b: any) => b.key === k)).map(k => (
|
||||
<option key={k} value={k}>{k}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { cn, buildLocationTree } from "@/lib/utils";
|
||||
import { LOCATION_ICONS, ITEM_ICONS } from "@/lib/constants/options";
|
||||
import { LOCATION_ICONS, ITEM_ICONS, BADGE_ICONS_MAP, BADGE_COLORS_MAP } from "@/lib/constants/options";
|
||||
|
||||
import { optimizeImage, formatBytes, getBase64Size } from "@/lib/imageUtils";
|
||||
|
||||
@@ -798,6 +798,126 @@ export function SmartAddForm({ locations, onSuccess }: SmartAddFormProps) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Badges Selection (Dynamic) */}
|
||||
<div className="bg-white dark:bg-gray-900/50 p-4 rounded-xl border border-dashed border-gray-300 dark:border-gray-700">
|
||||
<div>
|
||||
<Label className="flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
<Tag className="h-4 w-4" /> Badge hiển thị trên Card
|
||||
</Label>
|
||||
<p className="text-[10px] text-gray-500 mb-3">Chọn thông tin để hiển thị nổi bật.</p>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
// Default or existing badges
|
||||
const currentSpecs = form.watch("specs") || {};
|
||||
let currentBadges: any[] = currentSpecs.displayBadges || [];
|
||||
|
||||
const updateBadges = (newBadges: any[]) => {
|
||||
const newSpecs = { ...currentSpecs, displayBadges: newBadges };
|
||||
form.setValue("specs", newSpecs, { shouldDirty: true });
|
||||
};
|
||||
|
||||
// Get all available keys
|
||||
const specKeys = Object.keys(currentSpecs).filter(k => k !== 'displayBadges');
|
||||
const standardKeys = ['brand', 'type', 'purchaseLocation', 'warrantyEnd', 'purchasePrice'];
|
||||
const allKeys = Array.from(new Set([...standardKeys, ...specKeys]));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* List of active badges */}
|
||||
<div className="space-y-2">
|
||||
{currentBadges.map((badge: any, idx: number) => (
|
||||
<div key={idx} className="flex gap-2 items-center bg-gray-50 dark:bg-gray-800 p-2 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
{/* Field Select */}
|
||||
<div className="w-1/3 min-w-[120px]">
|
||||
<div className="text-[10px] uppercase font-bold text-gray-400 mb-0.5">Trường thông tin</div>
|
||||
<div className="font-medium text-sm truncate">{badge.key}</div>
|
||||
</div>
|
||||
|
||||
{/* Icon Select */}
|
||||
<div className="flex-1">
|
||||
<div className="text-[10px] uppercase font-bold text-gray-400 mb-0.5">Icon</div>
|
||||
<Select
|
||||
value={badge.icon || 'tag'}
|
||||
onChange={(e) => {
|
||||
const updated = [...currentBadges];
|
||||
updated[idx] = { ...updated[idx], icon: e.target.value };
|
||||
updateBadges(updated);
|
||||
}}
|
||||
className="h-8 text-xs w-full bg-white dark:bg-gray-900"
|
||||
>
|
||||
{Object.keys(BADGE_ICONS_MAP).map(k => (
|
||||
<option key={k} value={k}>{k}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Color Select */}
|
||||
<div className="flex-1">
|
||||
<div className="text-[10px] uppercase font-bold text-gray-400 mb-0.5">Màu</div>
|
||||
<Select
|
||||
value={badge.color || 'blue'}
|
||||
onChange={(e) => {
|
||||
const updated = [...currentBadges];
|
||||
updated[idx] = { ...updated[idx], color: e.target.value };
|
||||
updateBadges(updated);
|
||||
}}
|
||||
className="h-8 text-xs w-full bg-white dark:bg-gray-900"
|
||||
>
|
||||
{Object.keys(BADGE_COLORS_MAP).map(k => (
|
||||
<option key={k} value={k}>{k}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Remove */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-gray-400 hover:text-red-500 mt-4"
|
||||
onClick={() => {
|
||||
const updated = currentBadges.filter((_, i) => i !== idx);
|
||||
updateBadges(updated);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add New Badge */}
|
||||
<div className="flex gap-2 items-end pt-2">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
id="new-badge-select"
|
||||
className="h-9 text-sm bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
const newBadge = {
|
||||
key: e.target.value,
|
||||
icon: 'tag',
|
||||
color: 'blue'
|
||||
};
|
||||
updateBadges([...currentBadges, newBadge]);
|
||||
e.target.value = ""; // Reset select
|
||||
}
|
||||
}}
|
||||
value=""
|
||||
>
|
||||
<option value="">+ Thêm Badge hiển thị...</option>
|
||||
{allKeys.filter(k => !currentBadges.some((b: any) => b.key === k)).map(k => (
|
||||
<option key={k} value={k}>{k}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ interface LocationNode {
|
||||
name: string;
|
||||
type: string;
|
||||
icon?: string | null;
|
||||
image?: string | null;
|
||||
parentId?: string | null;
|
||||
children: LocationNode[];
|
||||
_count?: { items: number };
|
||||
@@ -393,28 +394,39 @@ export function LocationManager({ initialLocations }: { initialLocations: Locati
|
||||
<div className="flex-1 flex flex-col h-full">
|
||||
{/* Detail Header */}
|
||||
<div className="px-6 py-4 border-b border-primary-100 dark:border-gray-800 bg-primary-50/30 dark:bg-gray-800/40 flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
{(() => {
|
||||
if (selectedLocation?.icon) {
|
||||
const { icon: Icon, color } = LOCATION_ICONS[selectedLocation.icon] || LOCATION_ICONS['default'] || ITEM_ICONS['default'];
|
||||
return <Icon className={cn("h-6 w-6", color)} />;
|
||||
}
|
||||
return selectedLocation?.type === 'Person' ? <User className="text-purple-500" /> : selectedLocation?.type === 'Container' ? <Box className="text-primary-500" /> : <Folder className="text-amber-500" />
|
||||
})()}
|
||||
{selectedLocation?.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-2">
|
||||
<span className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-2 py-0.5 rounded text-xs">{selectedLocation?.type}</span>
|
||||
<span>•</span>
|
||||
<span>ID: {selectedLocation?.id.slice(0, 8)}</span>
|
||||
</p>
|
||||
<div className="flex items-start gap-4">
|
||||
{selectedLocation?.image ? (
|
||||
<div className="h-16 w-16 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden shadow-sm bg-white dark:bg-gray-800">
|
||||
<img src={selectedLocation.image} alt={selectedLocation.name} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-16 w-16 rounded-xl border border-primary-100 dark:border-gray-700 bg-white dark:bg-gray-800 flex items-center justify-center shadow-sm">
|
||||
{(() => {
|
||||
if (selectedLocation?.icon) {
|
||||
const { icon: Icon, color } = LOCATION_ICONS[selectedLocation.icon] || LOCATION_ICONS['default'] || ITEM_ICONS['default'];
|
||||
return <Icon className={cn("h-8 w-8", color)} />;
|
||||
}
|
||||
return selectedLocation?.type === 'Person' ? <User size={32} className="text-purple-500" /> : selectedLocation?.type === 'Container' ? <Box size={32} className="text-primary-500" /> : <Folder size={32} className="text-amber-500" />
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
{selectedLocation?.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-2">
|
||||
<span className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-2 py-0.5 rounded text-xs">{selectedLocation?.type}</span>
|
||||
<span>•</span>
|
||||
<span>ID: {selectedLocation?.id.slice(0, 8)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={async () => {
|
||||
setNewLocName(selectedLocation?.name || "");
|
||||
setNewLocType(selectedLocation?.type || "Fixed");
|
||||
setNewLocIcon(selectedLocation?.icon || "");
|
||||
setNewLocImage(selectedLocation?.image || null);
|
||||
setNewLocParentId(selectedLocation?.parentId || null);
|
||||
setIsEditing(true);
|
||||
}} className="bg-white dark:bg-gray-800 border border-primary-200 dark:border-gray-700 text-primary-700 dark:text-primary-300 hover:bg-primary-50 dark:hover:bg-primary-900/20 gap-1">
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Container, Backpack, Luggage,
|
||||
User, Users,
|
||||
Table, Layers,
|
||||
Zap, Clock, Info, DollarSign, Star, Award, ThumbsUp, Check, AlertTriangle, Settings, Search, Link2, Mail, Music, Video, Image,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -230,3 +231,92 @@ export const LOCATION_ICON_GROUPS = [
|
||||
items: ["Person", "Family"]
|
||||
}
|
||||
];
|
||||
|
||||
// Badge Configuration
|
||||
export const BADGE_ICONS_MAP: Record<string, any> = {
|
||||
// Hardware
|
||||
'cpu': Cpu,
|
||||
'circuit': CircuitBoard,
|
||||
'ram': MemoryStick,
|
||||
'fan': Fan,
|
||||
'battery': Battery,
|
||||
'wifi': Wifi,
|
||||
'bluetooth': Bluetooth,
|
||||
'server': Server,
|
||||
'harddrive': HardDrive,
|
||||
|
||||
// Abstract
|
||||
'zap': Zap,
|
||||
'tag': Tag,
|
||||
'box': Box,
|
||||
'clock': Clock,
|
||||
'user': User,
|
||||
'info': Info,
|
||||
'dollar': DollarSign,
|
||||
'lock': Lock,
|
||||
'shield': Shield,
|
||||
'star': Star,
|
||||
'award': Award,
|
||||
'thumbs-up': ThumbsUp,
|
||||
'check': Check,
|
||||
'alert': AlertTriangle,
|
||||
|
||||
// Tools
|
||||
'tools': Wrench,
|
||||
'settings': Settings,
|
||||
'search': Search,
|
||||
'link': Link2,
|
||||
|
||||
// Office
|
||||
'file': FileText,
|
||||
'folder': Folder,
|
||||
'archive': Archive,
|
||||
'mail': Mail,
|
||||
|
||||
// Fun
|
||||
'gamepad': Gamepad2,
|
||||
'music': Music,
|
||||
'video': Video,
|
||||
'image': Image,
|
||||
};
|
||||
|
||||
export const BADGE_COLORS_MAP: Record<string, string> = {
|
||||
'blue': "bg-blue-50 text-blue-700 border-blue-100",
|
||||
'red': "bg-red-50 text-red-700 border-red-100",
|
||||
'green': "bg-green-50 text-green-700 border-green-100",
|
||||
'emerald': "bg-emerald-50 text-emerald-700 border-emerald-100",
|
||||
'orange': "bg-orange-50 text-orange-700 border-orange-100",
|
||||
'amber': "bg-amber-50 text-amber-700 border-amber-100",
|
||||
'yellow': "bg-yellow-50 text-yellow-700 border-yellow-100",
|
||||
'purple': "bg-purple-50 text-purple-700 border-purple-100",
|
||||
'fuchsia': "bg-fuchsia-50 text-fuchsia-700 border-fuchsia-100",
|
||||
'pink': "bg-pink-50 text-pink-700 border-pink-100",
|
||||
'rose': "bg-rose-50 text-rose-700 border-rose-100",
|
||||
'cyan': "bg-cyan-50 text-cyan-700 border-cyan-100",
|
||||
'sky': "bg-sky-50 text-sky-700 border-sky-100",
|
||||
'indigo': "bg-indigo-50 text-indigo-700 border-indigo-100",
|
||||
'violet': "bg-violet-50 text-violet-700 border-violet-100",
|
||||
'slate': "bg-slate-50 text-slate-700 border-slate-100",
|
||||
'gray': "bg-gray-50 text-gray-700 border-gray-100",
|
||||
'stone': "bg-stone-50 text-stone-700 border-stone-100",
|
||||
'zinc': "bg-zinc-50 text-zinc-700 border-zinc-100",
|
||||
};
|
||||
|
||||
export const BADGE_ICON_GROUPS = [
|
||||
{
|
||||
label: "Phổ biến",
|
||||
items: ['tag', 'zap', 'battery', 'wifi', 'cpu', 'box', 'clock', 'user', 'dollar']
|
||||
},
|
||||
{
|
||||
label: "Phần cứng",
|
||||
items: ['circuit', 'ram', 'fan', 'server', 'harddrive', 'gamepad', 'video', 'music', 'image']
|
||||
},
|
||||
{
|
||||
label: "Hành chính",
|
||||
items: ['info', 'lock', 'shield', 'file', 'folder', 'archive', 'mail', 'link']
|
||||
},
|
||||
{
|
||||
label: "Trạng thái",
|
||||
items: ['check', 'alert', 'star', 'award', 'thumbs-up', 'settings', 'search', 'tools']
|
||||
}
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user