Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
76
src/app/(admin)/admin/settings/page.tsx
Normal file
76
src/app/(admin)/admin/settings/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Suspense } from 'react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { SettingsContent } from '@/components/settings/settings-content'
|
||||
|
||||
async function SettingsLoader() {
|
||||
const settings = await prisma.systemSettings.findMany({
|
||||
orderBy: [{ category: 'asc' }, { key: 'asc' }],
|
||||
})
|
||||
|
||||
// Convert settings array to key-value map
|
||||
// For secrets, pass a marker but not the actual value
|
||||
const settingsMap: Record<string, string> = {}
|
||||
settings.forEach((setting) => {
|
||||
if (setting.isSecret && setting.value) {
|
||||
// Pass marker for UI to show "existing" state
|
||||
settingsMap[setting.key] = '********'
|
||||
} else {
|
||||
settingsMap[setting.key] = setting.value
|
||||
}
|
||||
})
|
||||
|
||||
return <SettingsContent initialSettings={settingsMap} />
|
||||
}
|
||||
|
||||
function SettingsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const session = await auth()
|
||||
|
||||
// Only super admins can access settings
|
||||
if (session?.user?.role !== 'SUPER_ADMIN') {
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure platform settings and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Suspense fallback={<SettingsSkeleton />}>
|
||||
<SettingsLoader />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
717
src/app/(admin)/admin/settings/tags/page.tsx
Normal file
717
src/app/(admin)/admin/settings/tags/page.tsx
Normal file
@@ -0,0 +1,717 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Tags,
|
||||
Users,
|
||||
FolderKanban,
|
||||
GripVertical,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
|
||||
// Default categories
|
||||
const DEFAULT_CATEGORIES = [
|
||||
'Marine Science',
|
||||
'Technology',
|
||||
'Policy',
|
||||
'Conservation',
|
||||
'Business',
|
||||
'Education',
|
||||
'Engineering',
|
||||
'Other',
|
||||
]
|
||||
|
||||
// Default colors
|
||||
const TAG_COLORS = [
|
||||
{ value: '#de0f1e', label: 'Red' },
|
||||
{ value: '#053d57', label: 'Dark Blue' },
|
||||
{ value: '#557f8c', label: 'Teal' },
|
||||
{ value: '#059669', label: 'Green' },
|
||||
{ value: '#7c3aed', label: 'Purple' },
|
||||
{ value: '#ea580c', label: 'Orange' },
|
||||
{ value: '#0284c7', label: 'Blue' },
|
||||
{ value: '#be185d', label: 'Pink' },
|
||||
]
|
||||
|
||||
interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
category: string | null
|
||||
color: string | null
|
||||
isActive: boolean
|
||||
sortOrder: number
|
||||
userCount?: number
|
||||
projectCount?: number
|
||||
totalUsage?: number
|
||||
}
|
||||
|
||||
function SortableTagRow({
|
||||
tag,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
tag: Tag
|
||||
onEdit: (tag: Tag) => void
|
||||
onDelete: (tag: Tag) => void
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: tag.id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-center gap-3 rounded-lg border bg-card p-3 ${
|
||||
isDragging ? 'opacity-50 shadow-lg' : ''
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
|
||||
aria-label="Drag to reorder"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className="h-4 w-4 rounded-full shrink-0"
|
||||
style={{ backgroundColor: tag.color || '#6b7280' }}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{tag.name}</span>
|
||||
{!tag.isActive && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Inactive
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{tag.category && (
|
||||
<p className="text-xs text-muted-foreground">{tag.category}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1" title="Users with this tag">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
<span>{tag.userCount || 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1" title="Projects with this tag">
|
||||
<FolderKanban className="h-3.5 w-3.5" />
|
||||
<span>{tag.projectCount || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Tag actions">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(tag)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(tag)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TagsSettingsPage() {
|
||||
const utils = trpc.useUtils()
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||
const [editingTag, setEditingTag] = useState<Tag | null>(null)
|
||||
const [deletingTag, setDeletingTag] = useState<Tag | null>(null)
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all')
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [category, setCategory] = useState('')
|
||||
const [color, setColor] = useState('#557f8c')
|
||||
const [isActive, setIsActive] = useState(true)
|
||||
|
||||
// Queries
|
||||
const { data: tagsData, isLoading } = trpc.tag.list.useQuery({
|
||||
includeUsageCount: true,
|
||||
})
|
||||
const { data: categories } = trpc.tag.getCategories.useQuery()
|
||||
|
||||
// Mutations
|
||||
const createTag = trpc.tag.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Tag created successfully')
|
||||
setIsCreateOpen(false)
|
||||
resetForm()
|
||||
utils.tag.list.invalidate()
|
||||
utils.tag.getCategories.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const updateTag = trpc.tag.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Tag updated successfully')
|
||||
setEditingTag(null)
|
||||
resetForm()
|
||||
utils.tag.list.invalidate()
|
||||
utils.tag.getCategories.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteTag = trpc.tag.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Tag deleted successfully')
|
||||
setDeletingTag(null)
|
||||
utils.tag.list.invalidate()
|
||||
utils.tag.getCategories.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const reorderTags = trpc.tag.reorder.useMutation({
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
utils.tag.list.invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
const resetForm = () => {
|
||||
setName('')
|
||||
setDescription('')
|
||||
setCategory('')
|
||||
setColor('#557f8c')
|
||||
setIsActive(true)
|
||||
}
|
||||
|
||||
const openEditDialog = (tag: Tag) => {
|
||||
setEditingTag(tag)
|
||||
setName(tag.name)
|
||||
setDescription(tag.description || '')
|
||||
setCategory(tag.category || '')
|
||||
setColor(tag.color || '#557f8c')
|
||||
setIsActive(tag.isActive)
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!name.trim()) {
|
||||
toast.error('Please enter a tag name')
|
||||
return
|
||||
}
|
||||
createTag.mutate({
|
||||
name: name.trim(),
|
||||
description: description || undefined,
|
||||
category: category || undefined,
|
||||
color: color || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdate = () => {
|
||||
if (!editingTag || !name.trim()) return
|
||||
updateTag.mutate({
|
||||
id: editingTag.id,
|
||||
name: name.trim(),
|
||||
description: description || null,
|
||||
category: category || null,
|
||||
color: color || null,
|
||||
isActive,
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deletingTag) return
|
||||
deleteTag.mutate({ id: deletingTag.id })
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const tags = filteredTags
|
||||
const oldIndex = tags.findIndex((t) => t.id === active.id)
|
||||
const newIndex = tags.findIndex((t) => t.id === over.id)
|
||||
|
||||
const newOrder = arrayMove(tags, oldIndex, newIndex)
|
||||
const items = newOrder.map((tag, index) => ({
|
||||
id: tag.id,
|
||||
sortOrder: index,
|
||||
}))
|
||||
|
||||
reorderTags.mutate({ items })
|
||||
}
|
||||
}
|
||||
|
||||
// Filter tags by category
|
||||
const filteredTags = (tagsData?.tags || []).filter((tag) => {
|
||||
if (categoryFilter === 'all') return true
|
||||
if (categoryFilter === 'uncategorized') return !tag.category
|
||||
return tag.category === categoryFilter
|
||||
})
|
||||
|
||||
// Get unique categories for filter
|
||||
const allCategories = Array.from(
|
||||
new Set([
|
||||
...DEFAULT_CATEGORIES,
|
||||
...(categories || []),
|
||||
])
|
||||
).sort()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/settings">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<Tags className="h-6 w-6" />
|
||||
Expertise Tags
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage tags used for jury expertise and project categorization
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={() => resetForm()}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Tag
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Tag</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new expertise tag for categorizing jury members and projects
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Marine Biology"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this expertise area"
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category</Label>
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<SelectTrigger id="category">
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allCategories.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
{cat}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color">Color</Label>
|
||||
<Select value={color} onValueChange={setColor}>
|
||||
<SelectTrigger id="color">
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-4 w-4 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{TAG_COLORS.find((c) => c.value === color)?.label || 'Custom'}
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TAG_COLORS.map((c) => (
|
||||
<SelectItem key={c.value} value={c.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-4 w-4 rounded-full"
|
||||
style={{ backgroundColor: c.value }}
|
||||
/>
|
||||
{c.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCreateOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={createTag.isPending || !name.trim()}
|
||||
>
|
||||
{createTag.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Create Tag
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Filter by Category</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
<SelectItem value="uncategorized">Uncategorized</SelectItem>
|
||||
{allCategories.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
{cat}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tags List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tags ({filteredTags.length})</CardTitle>
|
||||
<CardDescription>
|
||||
Drag to reorder tags. Changes are saved automatically.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{filteredTags.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Tags className="mx-auto h-8 w-8 mb-2 opacity-50" />
|
||||
<p>No tags found</p>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
className="mt-2"
|
||||
>
|
||||
Create your first tag
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={filteredTags.map((t) => t.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{filteredTags.map((tag) => (
|
||||
<SortableTagRow
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
onEdit={openEditDialog}
|
||||
onDelete={setDeletingTag}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog
|
||||
open={!!editingTag}
|
||||
onOpenChange={(open) => !open && setEditingTag(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Tag</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update this expertise tag. Renaming will update all users and projects.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-name">Name *</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-description">Description</Label>
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-category">Category</Label>
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<SelectTrigger id="edit-category">
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{allCategories.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
{cat}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-color">Color</Label>
|
||||
<Select value={color} onValueChange={setColor}>
|
||||
<SelectTrigger id="edit-color">
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-4 w-4 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{TAG_COLORS.find((c) => c.value === color)?.label || 'Custom'}
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TAG_COLORS.map((c) => (
|
||||
<SelectItem key={c.value} value={c.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-4 w-4 rounded-full"
|
||||
style={{ backgroundColor: c.value }}
|
||||
/>
|
||||
{c.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="edit-active">Active</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inactive tags won't appear in selection lists
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="edit-active"
|
||||
checked={isActive}
|
||||
onCheckedChange={setIsActive}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditingTag(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
disabled={updateTag.isPending || !name.trim()}
|
||||
>
|
||||
{updateTag.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog
|
||||
open={!!deletingTag}
|
||||
onOpenChange={(open) => !open && setDeletingTag(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Tag</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{deletingTag?.name}"? This will
|
||||
remove the tag from {deletingTag?.userCount || 0} users and{' '}
|
||||
{deletingTag?.projectCount || 0} projects.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteTag.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user