name: favorites-manager description: Favorites and bookmarks management for HR-IMS version: 1.0.0 author: Claude Code triggers: keywords: ["favorite", "bookmark", "saved items", "star", "pin"] file_patterns: ["favorite", "bookmark", "lib/favorites*"] context: favorites, bookmarks, saved items, starred items, quick access mcp_servers: - sequential personas: - frontend - backend
Favorites Manager
Core Role
Manage favorites and bookmarks for HR-IMS:
- Favorite items
- Saved searches
- Quick access shortcuts
- Pinned items
Favorites Service
// lib/favorites/service.ts
import prisma from '@/lib/prisma'
export type FavoriteType = 'item' | 'request' | 'user' | 'warehouse' | 'search' | 'report'
export interface Favorite {
id: number
userId: number
type: FavoriteType
entityId: number | null
name: string
data?: Record<string, any>
sortOrder: number
createdAt: Date
}
// Add favorite
export async function addFavorite(data: {
userId: number
type: FavoriteType
entityId?: number
name: string
data?: Record<string, any>
}): Promise<Favorite> {
// Check if already exists
const existing = await prisma.favorite.findFirst({
where: {
userId: data.userId,
type: data.type,
entityId: data.entityId || null
}
})
if (existing) {
return existing
}
// Get max sort order
const maxSort = await prisma.favorite.aggregate({
where: {
userId: data.userId,
type: data.type
},
_max: { sortOrder: true }
})
const favorite = await prisma.favorite.create({
data: {
userId: data.userId,
type: data.type,
entityId: data.entityId || null,
name: data.name,
data: data.data || {},
sortOrder: (maxSort._max.sortOrder || 0) + 1
}
})
return favorite
}
// Remove favorite
export async function removeFavorite(options: {
userId: number
type: FavoriteType
entityId?: number
}): Promise<boolean> {
const result = await prisma.favorite.deleteMany({
where: {
userId: options.userId,
type: options.type,
entityId: options.entityId || null
}
})
return result.count > 0
}
// Remove favorite by ID
export async function removeFavoriteById(id: number, userId: number): Promise<boolean> {
const result = await prisma.favorite.deleteMany({
where: { id, userId }
})
return result.count > 0
}
// Check if favorited
export async function isFavorited(options: {
userId: number
type: FavoriteType
entityId?: number
}): Promise<boolean> {
const count = await prisma.favorite.count({
where: {
userId: options.userId,
type: options.type,
entityId: options.entityId || null
}
})
return count > 0
}
// Get user favorites by type
export async function getUserFavorites(
userId: number,
type?: FavoriteType
): Promise<Favorite[]> {
return prisma.favorite.findMany({
where: {
userId,
...(type && { type })
},
orderBy: { sortOrder: 'asc' }
})
}
// Get favorite items with details
export async function getFavoriteItems(userId: number): Promise<any[]> {
const favorites = await prisma.favorite.findMany({
where: {
userId,
type: 'item'
},
orderBy: { sortOrder: 'asc' }
})
if (favorites.length === 0) return []
const itemIds = favorites
.map(f => f.entityId)
.filter((id): id is number => id !== null)
const items = await prisma.inventoryItem.findMany({
where: { id: { in: itemIds } },
include: {
category: true,
stockLevels: {
include: { warehouse: true }
}
}
})
// Map to preserve order
const itemMap = new Map(items.map(item => [item.id, item]))
return favorites.map(fav => ({
...itemMap.get(fav.entityId!),
favoriteId: fav.id,
sortOrder: fav.sortOrder
})).filter(Boolean)
}
// Update sort order
export async function updateFavoriteOrder(
userId: number,
favoriteIds: number[]
): Promise<void> {
await Promise.all(
favoriteIds.map((id, index) =>
prisma.favorite.updateMany({
where: { id, userId },
data: { sortOrder: index }
})
)
)
}
// Save search
export async function saveSearch(data: {
userId: number
name: string
type: 'item' | 'request' | 'user'
filters: Record<string, any>
}): Promise<Favorite> {
return addFavorite({
userId: data.userId,
type: 'search',
name: data.name,
data: {
searchType: data.type,
filters: data.filters
}
})
}
// Get saved searches
export async function getSavedSearches(
userId: number,
searchType?: 'item' | 'request' | 'user'
): Promise<Favorite[]> {
const favorites = await prisma.favorite.findMany({
where: {
userId,
type: 'search',
...(searchType && {
data: {
path: ['searchType'],
equals: searchType
}
})
},
orderBy: { sortOrder: 'asc' }
})
return favorites
}
// Get recent favorites
export async function getRecentFavorites(
userId: number,
limit: number = 5
): Promise<Favorite[]> {
return prisma.favorite.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: limit
})
}
Favorites Hook
// hooks/use-favorites.ts
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useSession } from 'next-auth/react'
import {
addFavorite,
removeFavorite,
isFavorited,
getUserFavorites,
FavoriteType,
Favorite
} from '@/lib/favorites/service'
export function useFavorites(type?: FavoriteType) {
const { data: session } = useSession()
const [favorites, setFavorites] = useState<Favorite[]>([])
const [loading, setLoading] = useState(true)
const fetchFavorites = useCallback(async () => {
if (!session?.user?.id) return
setLoading(true)
try {
const data = await getUserFavorites(
parseInt(session.user.id),
type
)
setFavorites(data)
} finally {
setLoading(false)
}
}, [session?.user?.id, type])
useEffect(() => {
fetchFavorites()
}, [fetchFavorites])
const add = useCallback(async (
favType: FavoriteType,
entityId: number | undefined,
name: string,
data?: Record<string, any>
) => {
if (!session?.user?.id) return false
try {
await addFavorite({
userId: parseInt(session.user.id),
type: favType,
entityId,
name,
data
})
await fetchFavorites()
return true
} catch {
return false
}
}, [session?.user?.id, fetchFavorites])
const remove = useCallback(async (
favType: FavoriteType,
entityId: number | undefined
) => {
if (!session?.user?.id) return false
try {
await removeFavorite({
userId: parseInt(session.user.id),
type: favType,
entityId
})
await fetchFavorites()
return true
} catch {
return false
}
}, [session?.user?.id, fetchFavorites])
const isFavorite = useCallback(async (
favType: FavoriteType,
entityId: number | undefined
): Promise<boolean> => {
if (!session?.user?.id) return false
return isFavorited({
userId: parseInt(session.user.id),
type: favType,
entityId
})
}, [session?.user?.id])
return {
favorites,
loading,
add,
remove,
isFavorite,
refresh: fetchFavorites
}
}
// Single item favorite check
export function useIsFavorite(type: FavoriteType, entityId: number | undefined) {
const { data: session } = useSession()
const [isFav, setIsFav] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
const check = async () => {
if (!session?.user?.id || entityId === undefined) {
setLoading(false)
return
}
setLoading(true)
const result = await isFavorited({
userId: parseInt(session.user.id),
type,
entityId
})
setIsFav(result)
setLoading(false)
}
check()
}, [session?.user?.id, type, entityId])
return { isFavorite: isFav, loading }
}
Favorite Button Component
// components/favorites/favorite-button.tsx
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Star } from 'lucide-react'
import { cn } from '@/lib/utils'
import { FavoriteType } from '@/lib/favorites/service'
interface FavoriteButtonProps {
type: FavoriteType
entityId: number
name: string
className?: string
showLabel?: boolean
size?: 'sm' | 'md' | 'lg'
onToggle?: (isFavorite: boolean) => void
}
export function FavoriteButton({
type,
entityId,
name,
className,
showLabel = false,
size = 'md',
onToggle
}: FavoriteButtonProps) {
const [isFavorite, setIsFavorite] = useState(false)
const [loading, setLoading] = useState(false)
// Check initial state
useEffect(() => {
const check = async () => {
const response = await fetch(`/api/favorites/check?type=${type}&entityId=${entityId}`)
const data = await response.json()
setIsFavorite(data.isFavorite)
}
check()
}, [type, entityId])
const handleToggle = async () => {
setLoading(true)
try {
if (isFavorite) {
await fetch(`/api/favorites?type=${type}&entityId=${entityId}`, {
method: 'DELETE'
})
setIsFavorite(false)
onToggle?.(false)
} else {
await fetch('/api/favorites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, entityId, name })
})
setIsFavorite(true)
onToggle?.(true)
}
} finally {
setLoading(false)
}
}
const sizeClasses = {
sm: 'h-8 w-8',
md: 'h-9 w-9',
lg: 'h-10 w-10'
}
return (
<Button
variant="ghost"
size="icon"
className={cn(sizeClasses[size], className)}
onClick={handleToggle}
disabled={loading}
title={isFavorite ? 'นำออกจากรายการโปรด / Remove from favorites' : 'เพิ่มในรายการโปรด / Add to favorites'}
>
<Star
className={cn(
'h-4 w-4 transition-colors',
isFavorite ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground'
)}
/>
{showLabel && (
<span className="ml-2">
{isFavorite ? 'นำออก' : 'บันทึก'}
</span>
)}
</Button>
)
}
Favorites List Component
// components/favorites/favorites-list.tsx
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Star, Trash2, GripVertical, Package, FileText, Search } from 'lucide-react'
import { Favorite, FavoriteType } from '@/lib/favorites/service'
import {
DragDropContext,
Droppable,
Draggable,
DropResult
} from '@hello-pangea/dnd'
interface FavoritesListProps {
type?: FavoriteType
limit?: number
showReorder?: boolean
}
export function FavoritesList({
type,
limit,
showReorder = false
}: FavoritesListProps) {
const [favorites, setFavorites] = useState<Favorite[]>([])
const [loading, setLoading] = useState(true)
const fetchFavorites = async () => {
setLoading(true)
try {
const params = new URLSearchParams()
if (type) params.append('type', type)
if (limit) params.append('limit', limit.toString())
const response = await fetch(`/api/favorites?${params}`)
const data = await response.json()
setFavorites(data.favorites || [])
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchFavorites()
}, [type, limit])
const handleRemove = async (id: number) => {
await fetch(`/api/favorites/${id}`, { method: 'DELETE' })
fetchFavorites()
}
const handleDragEnd = async (result: DropResult) => {
if (!result.destination) return
const items = Array.from(favorites)
const [reorderedItem] = items.splice(result.source.index, 1)
items.splice(result.destination.index, 0, reorderedItem)
setFavorites(items)
// Update order on server
await fetch('/api/favorites/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
favoriteIds: items.map(f => f.id)
})
})
}
const getTypeIcon = (favType: FavoriteType) => {
switch (favType) {
case 'item': return Package
case 'request': return FileText
case 'search': return Search
default: return Star
}
}
const getItemLink = (favorite: Favorite): string => {
switch (favorite.type) {
case 'item': return `/inventory/${favorite.entityId}`
case 'request': return `/requests/${favorite.entityId}`
case 'user': return `/users/${favorite.entityId}`
case 'warehouse': return `/warehouse/${favorite.entityId}`
case 'search': return `/inventory?saved=${favorite.id}`
default: return '#'
}
}
if (loading) {
return <div className="text-center py-4">Loading...</div>
}
if (favorites.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
<Star className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>ไม่มีรายการโปรด / No favorites</p>
</div>
)
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-lg flex items-center gap-2">
<Star className="h-5 w-5 text-yellow-400 fill-yellow-400" />
รายการโปรด / Favorites
</CardTitle>
</CardHeader>
<CardContent>
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="favorites">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef}>
<ScrollArea className="h-[300px]">
<div className="space-y-2">
{favorites.map((favorite, index) => {
const Icon = getTypeIcon(favorite.type)
return (
<Draggable
key={favorite.id}
draggableId={favorite.id.toString()}
index={index}
isDragDisabled={!showReorder}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
className={`
flex items-center gap-2 p-2 rounded-lg
hover:bg-muted transition-colors
${snapshot.isDragging ? 'bg-muted shadow-lg' : ''}
`}
>
{showReorder && (
<div {...provided.dragHandleProps}>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
)}
<Icon className="h-4 w-4 text-muted-foreground" />
<Link
href={getItemLink(favorite)}
className="flex-1 truncate text-sm hover:underline"
>
{favorite.name}
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(favorite.id)}
className="h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
)}
</Draggable>
)
})}
{provided.placeholder}
</div>
</ScrollArea>
</div>
)}
</Droppable>
</DragDropContext>
</CardContent>
</Card>
)
}
Quick Access Sidebar
// components/favorites/quick-access.tsx
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Star, ChevronRight, MoreHorizontal } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Favorite, FavoriteType } from '@/lib/favorites/service'
interface QuickAccessProps {
collapsed?: boolean
}
export function QuickAccess({ collapsed = false }: QuickAccessProps) {
const [favorites, setFavorites] = useState<Favorite[]>([])
useEffect(() => {
const fetch = async () => {
const response = await fetch('/api/favorites?limit=10')
const data = await response.json()
setFavorites(data.favorites || [])
}
fetch()
}, [])
const getItemLink = (favorite: Favorite): string => {
switch (favorite.type) {
case 'item': return `/inventory/${favorite.entityId}`
case 'request': return `/requests/${favorite.entityId}`
default: return '#'
}
}
if (collapsed) {
return (
<div className="p-2">
<Star className="h-5 w-5 text-yellow-400 fill-yellow-400" />
</div>
)
}
return (
<div className="py-2">
<div className="flex items-center justify-between px-3 mb-2">
<span className="text-xs font-medium text-muted-foreground uppercase">
Quick Access
</span>
<Link
href="/favorites"
className="text-xs text-primary hover:underline"
>
View All
</Link>
</div>
<ScrollArea className="h-[200px]">
<div className="space-y-1 px-2">
{favorites.length === 0 ? (
<p className="text-xs text-muted-foreground px-2">
No favorites yet
</p>
) : (
favorites.map((favorite) => (
<Link
key={favorite.id}
href={getItemLink(favorite)}
className="flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted"
>
<Star className="h-3 w-3 text-yellow-400 fill-yellow-400" />
<span className="truncate">{favorite.name}</span>
</Link>
))
)}
</div>
</ScrollArea>
</div>
)
}
API Routes
// app/api/favorites/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import {
addFavorite,
getUserFavorites,
removeFavorite,
FavoriteType
} from '@/lib/favorites/service'
export async function GET(request: NextRequest) {
const session = await auth()
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const type = searchParams.get('type') as FavoriteType | null
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined
const favorites = await getUserFavorites(
parseInt(session.user.id),
type || undefined
)
return NextResponse.json({
favorites: limit ? favorites.slice(0, limit) : favorites
})
}
export async function POST(request: NextRequest) {
const session = await auth()
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { type, entityId, name, data } = body
const favorite = await addFavorite({
userId: parseInt(session.user.id),
type,
entityId,
name,
data
})
return NextResponse.json({ favorite })
}
export async function DELETE(request: NextRequest) {
const session = await auth()
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const type = searchParams.get('type') as FavoriteType
const entityId = searchParams.get('entityId') ? parseInt(searchParams.get('entityId')!) : undefined
await removeFavorite({
userId: parseInt(session.user.id),
type,
entityId
})
return NextResponse.json({ success: true })
}
// app/api/favorites/check/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { isFavorited, FavoriteType } from '@/lib/favorites/service'
export async function GET(request: NextRequest) {
const session = await auth()
if (!session) {
return NextResponse.json({ isFavorite: false })
}
const { searchParams } = new URL(request.url)
const type = searchParams.get('type') as FavoriteType
const entityId = searchParams.get('entityId') ? parseInt(searchParams.get('entityId')!) : undefined
const isFavorite = await isFavorited({
userId: parseInt(session.user.id),
type,
entityId
})
return NextResponse.json({ isFavorite })
}
Usage Examples
// Example 1: Favorite button on item card
import { FavoriteButton } from '@/components/favorites/favorite-button'
function ItemCard({ item }) {
return (
<div className="border rounded-lg p-4">
<div className="flex justify-between items-start">
<h3>{item.name}</h3>
<FavoriteButton
type="item"
entityId={item.id}
name={item.name}
/>
</div>
<p>{item.description}</p>
</div>
)
}
// Example 2: Favorites sidebar
import { FavoritesList } from '@/components/favorites/favorites-list'
function Sidebar() {
return (
<aside>
<FavoritesList type="item" limit={5} />
</aside>
)
}
// Example 3: Save search
import { saveSearch } from '@/lib/favorites/service'
function SearchForm() {
const handleSaveSearch = async () => {
await saveSearch({
userId: session.user.id,
name: 'My filtered items',
type: 'item',
filters: { category: 'electronics', status: 'available' }
})
}
return (
<Button onClick={handleSaveSearch}>
Save Search
</Button>
)
}
// Example 4: Use favorites hook
import { useFavorites } from '@/hooks/use-favorites'
function FavoritesPage() {
const { favorites, loading, add, remove, isFavorite } = useFavorites('item')
return (
<div>
{favorites.map(fav => (
<div key={fav.id}>
{fav.name}
<button onClick={() => remove('item', fav.entityId!)}>
Remove
</button>
</div>
))}
</div>
)
}
// Example 5: Quick access in sidebar
import { QuickAccess } from '@/components/favorites/quick-access'
function MainLayout({ children }) {
return (
<div className="flex">
<aside>
<QuickAccess />
</aside>
<main>{children}</main>
</div>
)
}
Version: 1.0.0 | For HR-IMS Project