Phase 1 Sprint C+D: Admin UI + Frontend Sidebar
This commit is contained in:
@@ -5,10 +5,11 @@ import { getSession } from '@/lib/auth'
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const user = await getSession()
|
const user = await getSession()
|
||||||
|
const tenantId = user?.tenantId
|
||||||
|
|
||||||
// Filter categories: global (tenantId=null) + tenant-specific
|
/* ─── 1. Global library (legacy IconAsset) ─── */
|
||||||
const categoryWhere: any = user?.tenantId
|
const categoryWhere: any = tenantId
|
||||||
? { OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
|
? { OR: [{ tenantId: null }, { tenantId }] }
|
||||||
: {}
|
: {}
|
||||||
|
|
||||||
const categories = await (prisma as any).iconCategory.findMany({
|
const categories = await (prisma as any).iconCategory.findMany({
|
||||||
@@ -16,19 +17,18 @@ export async function GET() {
|
|||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
icons: {
|
icons: {
|
||||||
where: user?.tenantId
|
where: tenantId
|
||||||
? { isActive: true, OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
|
? { isActive: true, OR: [{ tenantId: null }, { tenantId }] }
|
||||||
: { isActive: true, tenantId: null },
|
: { isActive: true, tenantId: null },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get tenant's hidden icon IDs (legacy)
|
|
||||||
let hiddenIconIds: string[] = []
|
let hiddenIconIds: string[] = []
|
||||||
if (user?.tenantId) {
|
if (tenantId) {
|
||||||
const tenant = await (prisma as any).tenant.findUnique({
|
const tenant = await (prisma as any).tenant.findUnique({
|
||||||
where: { id: user.tenantId },
|
where: { id: tenantId },
|
||||||
select: { hiddenIconIds: true },
|
select: { hiddenIconIds: true },
|
||||||
})
|
})
|
||||||
hiddenIconIds = tenant?.hiddenIconIds || []
|
hiddenIconIds = tenant?.hiddenIconIds || []
|
||||||
@@ -44,15 +44,72 @@ export async function GET() {
|
|||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Get tenant's custom symbol collection (with custom names)
|
/* ─── 2. Tenant symbols (Phase 1 architecture) ─── */
|
||||||
let mySymbols: any[] = []
|
let tenantSymbolGroups: any[] = []
|
||||||
if (user?.tenantId) {
|
let flatTenantSymbols: any[] = []
|
||||||
|
|
||||||
|
if (tenantId) {
|
||||||
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
|
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
|
||||||
where: { tenantId: user.tenantId },
|
where: { tenantId },
|
||||||
|
include: { category: true },
|
||||||
|
orderBy: [{ category: { sortOrder: 'asc' } }, { sortOrder: 'asc' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
flatTenantSymbols = tenantSymbols.map((ts: any) => ({
|
||||||
|
id: ts.id,
|
||||||
|
name: ts.customName || ts.name,
|
||||||
|
customName: ts.customName,
|
||||||
|
categoryId: ts.categoryId,
|
||||||
|
categoryName: ts.category?.name || null,
|
||||||
|
isUploaded: ts.isUploaded,
|
||||||
|
migratedFromIconId: ts.migratedFromIconId,
|
||||||
|
imageUrl: ts.isUploaded
|
||||||
|
? `/api/tenant/symbols/${ts.id}/image`
|
||||||
|
: ts.migratedFromIconId
|
||||||
|
? `/api/icons/${ts.migratedFromIconId}/image`
|
||||||
|
: `/api/icons/${ts.id}/image`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Group by category for sidebar display
|
||||||
|
const groups = new Map<string | null, any[]>()
|
||||||
|
for (const sym of flatTenantSymbols) {
|
||||||
|
const key = sym.categoryId || null
|
||||||
|
if (!groups.has(key)) groups.set(key, [])
|
||||||
|
groups.get(key)!.push(sym)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch categories that have symbols but may not be in the symbol list (empty ones are omitted)
|
||||||
|
const catIds = Array.from(groups.keys()).filter(Boolean) as string[]
|
||||||
|
const tenantCategories = catIds.length
|
||||||
|
? await (prisma as any).tenantCategory.findMany({
|
||||||
|
where: { id: { in: catIds }, tenantId },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
const catMap = new Map(tenantCategories.map((c: any) => [c.id, c]))
|
||||||
|
|
||||||
|
tenantSymbolGroups = Array.from(groups.entries()).map(([catId, symbols]) => {
|
||||||
|
const cat = catId ? catMap.get(catId) : null
|
||||||
|
return {
|
||||||
|
categoryId: catId,
|
||||||
|
categoryName: cat ? (cat as any).name || 'Kategorie' : 'Ohne Kategorie',
|
||||||
|
sortOrder: cat ? (cat as any).sortOrder ?? 999 : 999,
|
||||||
|
symbols,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
tenantSymbolGroups.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 3. Legacy mySymbols (keep for old clients during transition) ─── */
|
||||||
|
let mySymbolsLegacy: any[] = []
|
||||||
|
if (tenantId) {
|
||||||
|
const legacy = await (prisma as any).tenantSymbol.findMany({
|
||||||
|
where: { tenantId, iconId: { not: null } },
|
||||||
include: { icon: { select: { id: true, name: true, mimeType: true, iconType: true } } },
|
include: { icon: { select: { id: true, name: true, mimeType: true, iconType: true } } },
|
||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
})
|
})
|
||||||
mySymbols = tenantSymbols.map((ts: any) => ({
|
mySymbolsLegacy = legacy.map((ts: any) => ({
|
||||||
id: ts.icon.id,
|
id: ts.icon.id,
|
||||||
tenantSymbolId: ts.id,
|
tenantSymbolId: ts.id,
|
||||||
name: ts.customName || ts.icon.name,
|
name: ts.customName || ts.icon.name,
|
||||||
@@ -63,7 +120,12 @@ export async function GET() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ categories: categoriesWithUrls, mySymbols })
|
return NextResponse.json({
|
||||||
|
categories: categoriesWithUrls,
|
||||||
|
mySymbols: mySymbolsLegacy, // legacy shape for old clients
|
||||||
|
tenantSymbols: flatTenantSymbols, // new flat list
|
||||||
|
tenantSymbolGroups, // grouped by TenantCategory
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching icons:', error)
|
console.error('Error fetching icons:', error)
|
||||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||||
|
|||||||
83
src/app/api/tenant/symbols/[id]/image/route.ts
Normal file
83
src/app/api/tenant/symbols/[id]/image/route.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/db'
|
||||||
|
import { getFileStream } from '@/lib/minio'
|
||||||
|
import { readFile } from 'fs/promises'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
async function getTenantId() {
|
||||||
|
const { headers } = await import('next/headers')
|
||||||
|
const h = await headers()
|
||||||
|
return h.get('x-tenant-id') || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const tenantId = await getTenantId()
|
||||||
|
|
||||||
|
const symbol = await (prisma as any).tenantSymbol.findFirst({
|
||||||
|
where: { id, tenantId },
|
||||||
|
include: { category: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!symbol) {
|
||||||
|
return new NextResponse('Not found', { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Uploaded file → MinIO
|
||||||
|
if (symbol.isUploaded && symbol.svgPath) {
|
||||||
|
try {
|
||||||
|
const { stream, contentType } = await getFileStream(symbol.svgPath)
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of stream as any) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||||
|
}
|
||||||
|
const buffer = Buffer.concat(chunks)
|
||||||
|
return new NextResponse(buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('MinIO stream error:', err)
|
||||||
|
return new NextResponse('File not available', { status: 502 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Migrated from IconAsset → serve from public/signaturen
|
||||||
|
if (symbol.migratedFromIconId) {
|
||||||
|
const icon = await (prisma as any).iconAsset.findUnique({
|
||||||
|
where: { id: symbol.migratedFromIconId },
|
||||||
|
})
|
||||||
|
if (icon?.fileKey) {
|
||||||
|
const filePath = join(process.cwd(), 'public', icon.fileKey)
|
||||||
|
try {
|
||||||
|
const buffer = await readFile(filePath)
|
||||||
|
const ext = icon.fileKey.split('.').pop()?.toLowerCase()
|
||||||
|
const mimeType =
|
||||||
|
ext === 'svg' ? 'image/svg+xml' :
|
||||||
|
ext === 'png' ? 'image/png' :
|
||||||
|
ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' :
|
||||||
|
'application/octet-stream'
|
||||||
|
return new NextResponse(buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': mimeType,
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// fall through to 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse('Not found', { status: 404 })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Tenant symbol image error:', err)
|
||||||
|
return new NextResponse('Internal error', { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ import {
|
|||||||
Search, Flame, Droplets, AlertTriangle, Car, Users,
|
Search, Flame, Droplets, AlertTriangle, Car, Users,
|
||||||
Truck, Building, Target, Upload, Loader2, X, LayoutGrid,
|
Truck, Building, Target, Upload, Loader2, X, LayoutGrid,
|
||||||
ChevronLeft, ChevronRight, Map, ClipboardList, PanelRightClose, PanelRightOpen,
|
ChevronLeft, ChevronRight, Map, ClipboardList, PanelRightClose, PanelRightOpen,
|
||||||
Shield, Wrench, Radio, MoreHorizontal, Heart,
|
Shield, Wrench, Radio, MoreHorizontal, Heart, FolderOpen,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
interface DisplaySymbol {
|
interface DisplaySymbol {
|
||||||
@@ -24,6 +24,12 @@ interface DisplayCategory {
|
|||||||
symbols: DisplaySymbol[]
|
symbols: DisplaySymbol[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TenantSymbolGroup {
|
||||||
|
categoryId: string | null
|
||||||
|
categoryName: string
|
||||||
|
symbols: DisplaySymbol[]
|
||||||
|
}
|
||||||
|
|
||||||
interface RightSidebarProps {
|
interface RightSidebarProps {
|
||||||
onSymbolDrop: (iconId: string, coordinates: [number, number], imageUrl?: string) => void
|
onSymbolDrop: (iconId: string, coordinates: [number, number], imageUrl?: string) => void
|
||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
@@ -99,10 +105,11 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
|||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [activeCategory, setActiveCategory] = useState<string>('')
|
const [activeCategory, setActiveCategory] = useState<string>('')
|
||||||
const [categories, setCategories] = useState<DisplayCategory[]>([])
|
const [categories, setCategories] = useState<DisplayCategory[]>([])
|
||||||
const [tenantIcons, setTenantIcons] = useState<DisplaySymbol[]>([])
|
const [tenantGroups, setTenantGroups] = useState<TenantSymbolGroup[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [showTenantSection, setShowTenantSection] = useState(true)
|
const [showTenantSection, setShowTenantSection] = useState(true)
|
||||||
const [showLibrarySection, setShowLibrarySection] = useState(true)
|
const [showLibrarySection, setShowLibrarySection] = useState(true)
|
||||||
|
const [expandedTenantCats, setExpandedTenantCats] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchIcons() {
|
async function fetchIcons() {
|
||||||
@@ -111,6 +118,8 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
|||||||
const res = await fetch('/api/icons', { cache: 'no-store' })
|
const res = await fetch('/api/icons', { cache: 'no-store' })
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
|
// ─── Global library ───
|
||||||
const allCats: DisplayCategory[] = (data.categories || [])
|
const allCats: DisplayCategory[] = (data.categories || [])
|
||||||
.filter((cat: any) => cat.icons && cat.icons.length > 0)
|
.filter((cat: any) => cat.icons && cat.icons.length > 0)
|
||||||
.map((cat: any) => ({
|
.map((cat: any) => ({
|
||||||
@@ -123,27 +132,41 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
|||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Separate tenant-specific icons ("Eigene" category) from global library
|
// Separate tenant-specific legacy "Eigene" category from global library
|
||||||
const eigene = allCats.find(c => c.name === 'Eigene')
|
const eigene = allCats.find(c => c.name === 'Eigene')
|
||||||
const globalCats = allCats.filter(c => c.name !== 'Eigene')
|
const globalCats = allCats.filter(c => c.name !== 'Eigene')
|
||||||
|
|
||||||
// Merge: mySymbols (custom collection) + legacy "Eigene" category uploads
|
setCategories(globalCats)
|
||||||
const mySymbols: DisplaySymbol[] = (data.mySymbols || []).map((s: any) => ({
|
if (globalCats.length > 0 && !activeCategory) {
|
||||||
|
setActiveCategory(globalCats[0].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── New tenant symbol groups (Phase 1) ───
|
||||||
|
const groups: TenantSymbolGroup[] = (data.tenantSymbolGroups || []).map((g: any) => ({
|
||||||
|
categoryId: g.categoryId,
|
||||||
|
categoryName: g.categoryName,
|
||||||
|
symbols: g.symbols.map((s: any) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
imageUrl: s.url || `/api/icons/${s.id}/image`,
|
imageUrl: s.imageUrl || `/api/icons/${s.id}/image`,
|
||||||
|
})),
|
||||||
}))
|
}))
|
||||||
const legacyOwn = eigene?.symbols || []
|
|
||||||
// Deduplicate: mySymbols takes priority over legacy
|
|
||||||
const mySymbolIds = new Set(mySymbols.map(s => s.id))
|
|
||||||
const mergedTenant = [...mySymbols, ...legacyOwn.filter(s => !mySymbolIds.has(s.id))]
|
|
||||||
|
|
||||||
setTenantIcons(mergedTenant)
|
// Merge legacy "Eigene" into tenant groups if present
|
||||||
setCategories(globalCats)
|
if (eigene && eigene.symbols.length > 0) {
|
||||||
if (globalCats.length > 0) setActiveCategory(globalCats[0].id)
|
const legacyGroup: TenantSymbolGroup = {
|
||||||
|
categoryId: '__legacy__',
|
||||||
|
categoryName: 'Eigene',
|
||||||
|
symbols: eigene.symbols,
|
||||||
|
}
|
||||||
|
groups.unshift(legacyGroup)
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-collapse library if tenant has own symbols
|
setTenantGroups(groups)
|
||||||
if (mergedTenant.length > 0) {
|
|
||||||
|
// Auto-expand all tenant groups, auto-collapse library if tenant has symbols
|
||||||
|
if (groups.length > 0 && groups.some(g => g.symbols.length > 0)) {
|
||||||
|
setExpandedTenantCats(new Set(groups.map(g => g.categoryId || '__none__')))
|
||||||
setShowLibrarySection(false)
|
setShowLibrarySection(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,18 +179,31 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
|||||||
fetchIcons()
|
fetchIcons()
|
||||||
}, [tenantId])
|
}, [tenantId])
|
||||||
|
|
||||||
|
const toggleTenantCat = (catId: string | null) => {
|
||||||
|
const key = catId || '__none__'
|
||||||
|
setExpandedTenantCats(prev => {
|
||||||
|
const n = new Set(prev)
|
||||||
|
n.has(key) ? n.delete(key) : n.add(key)
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const filteredCategories = categories.map((cat) => ({
|
const filteredCategories = categories.map((cat) => ({
|
||||||
...cat,
|
...cat,
|
||||||
symbols: cat.symbols.filter((s) =>
|
symbols: cat.symbols.filter((s) =>
|
||||||
s.name.toLowerCase().includes(searchQuery.toLowerCase())
|
s.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
const filteredTenantIcons = tenantIcons.filter(s =>
|
const filteredTenantGroups = tenantGroups.map(g => ({
|
||||||
|
...g,
|
||||||
|
symbols: g.symbols.filter(s =>
|
||||||
s.name.toLowerCase().includes(searchQuery.toLowerCase())
|
s.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
)
|
),
|
||||||
|
})).filter(g => g.symbols.length > 0)
|
||||||
|
|
||||||
const currentCategory = filteredCategories.find((c) => c.id === activeCategory)
|
const currentCategory = filteredCategories.find((c) => c.id === activeCategory)
|
||||||
const totalSymbols = categories.reduce((sum, c) => sum + c.symbols.length, 0) + tenantIcons.length
|
const totalSymbols = categories.reduce((sum, c) => sum + c.symbols.length, 0) +
|
||||||
|
tenantGroups.reduce((sum, g) => sum + g.symbols.length, 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -298,7 +334,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
{/* ─── Section 1: Meine Symbole (Tenant-specific) ─── */}
|
{/* ─── Section 1: Meine Symbole (Tenant Symbol Groups) ─── */}
|
||||||
{tenantId && (
|
{tenantId && (
|
||||||
<div className="border-b border-border">
|
<div className="border-b border-border">
|
||||||
<button
|
<button
|
||||||
@@ -307,24 +343,47 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
|||||||
>
|
>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Upload className="w-3.5 h-3.5" />
|
<Upload className="w-3.5 h-3.5" />
|
||||||
Meine Symbole ({filteredTenantIcons.length})
|
Meine Symbole ({tenantGroups.reduce((s, g) => s + g.symbols.length, 0)})
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showTenantSection ? 'rotate-90' : ''}`} />
|
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showTenantSection ? 'rotate-90' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
{showTenantSection && (
|
{showTenantSection && (
|
||||||
<div className="p-2 pt-0">
|
<div className="p-2 pt-0 space-y-1">
|
||||||
{filteredTenantIcons.length === 0 ? (
|
{filteredTenantGroups.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground py-4 text-xs">
|
<div className="text-center text-muted-foreground py-4 text-xs">
|
||||||
Keine eigenen Symbole vorhanden.
|
Keine eigenen Symbole vorhanden.
|
||||||
<br />
|
<br />
|
||||||
<span className="text-[10px]">Symbole können unter Einstellungen → Symbole hochgeladen werden.</span>
|
<span className="text-[10px]">Symbole können unter Admin → Symbole verwaltet werden.</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
filteredTenantGroups.map(g => {
|
||||||
|
const key = g.categoryId || '__none__'
|
||||||
|
const expanded = expandedTenantCats.has(key)
|
||||||
|
return (
|
||||||
|
<div key={key} className="border rounded-md">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleTenantCat(g.categoryId)}
|
||||||
|
className="w-full flex items-center justify-between px-2 py-1 text-[11px] font-medium hover:bg-muted/40 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<FolderOpen className="w-3 h-3 text-muted-foreground" />
|
||||||
|
{g.categoryName}
|
||||||
|
<span className="text-[10px] text-muted-foreground">({g.symbols.length})</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight className={`w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-1.5 pb-1.5">
|
||||||
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
|
<div className="grid grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-1">
|
||||||
{filteredTenantIcons.map((symbol) => (
|
{g.symbols.map(symbol => (
|
||||||
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} />
|
<DraggableSymbol key={symbol.id} symbol={symbol} canEdit={canEdit} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user