diff --git a/.env.example b/.env.example index c460a8b..cadd7b7 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,11 @@ REUI_LICENSE_KEY= PANEL_MODE=standalone TELEMT_API_URL=http://127.0.0.1:9091 TELEMT_AUTH_HEADER= + +# REQUIRED in production (≥ 8 chars). Generate: openssl rand -hex 32 JWT_SECRET=dev-secret-change-me-please JWT_TTL_HOURS=24 + PANEL_ENCRYPTION_KEY=dev-encryption-key-change-me PANEL_PUBLIC_URL=http://127.0.0.1:8080 BOOTSTRAP_USERNAME=admin diff --git a/README.md b/README.md index 0e8b78f..515bb92 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,22 @@ Image: `git.shts.su/denozord/telemtpanel` ## Quick start (standalone) +**Обязательно** задайте `JWT_SECRET` (≥ 8 символов) — без него контейнер не запустится. + ```bash docker pull git.shts.su/denozord/telemtpanel:latest docker run -d --name telemt-panel --network host \ -e PANEL_MODE=standalone \ -e TELEMT_API_URL=http://127.0.0.1:9091 \ - -e JWT_SECRET=change-me-long \ + -e JWT_SECRET="$(openssl rand -hex 32)" \ + -e BOOTSTRAP_USERNAME=admin \ -e BOOTSTRAP_PASSWORD=change-me \ -v /var/lib/telemt-panel:/data \ git.shts.su/denozord/telemtpanel:latest ``` +UI: `http://:8080` (логин `admin` / `BOOTSTRAP_PASSWORD`). + Полная инструкция (RU): **[docs/install.md](docs/install.md)**. Также: [telemt-control-api.md](docs/telemt-control-api.md), [agent-protocol.md](docs/agent-protocol.md). diff --git a/apps/api/src/routes/telemt.ts b/apps/api/src/routes/telemt.ts index 3031ad4..7c315e2 100644 --- a/apps/api/src/routes/telemt.ts +++ b/apps/api/src/routes/telemt.ts @@ -9,7 +9,9 @@ import { sha256, randomBytes } from './auth.js' export async function telemtRoutes(app: FastifyInstance) { app.all('/api/telemt/*', { preHandler: requireAuth }, async (request, reply) => { const suffix = (request.params as { '*': string })['*'] - const path = `/v1/${suffix}` + const qsIndex = request.url.indexOf('?') + const query = qsIndex >= 0 ? request.url.slice(qsIndex) : '' + const path = `/v1/${suffix}${query}` const method = request.method.toUpperCase() if (app.config.panelMode === 'standalone') { @@ -96,7 +98,9 @@ export async function fleetRoutes(app: FastifyInstance) { app.all('/api/servers/:id/telemt/*', { preHandler: requireAuth }, async (request, reply) => { const { id } = request.params as { id: string } const suffix = (request.params as { id: string; '*': string })['*'] - const path = `/v1/${suffix}` + const qsIndex = request.url.indexOf('?') + const query = qsIndex >= 0 ? request.url.slice(qsIndex) : '' + const path = `/v1/${suffix}${query}` const method = request.method.toUpperCase() if (app.config.panelMode === 'standalone' || id === 'local') { diff --git a/apps/web/src/components/blocks/data-grid-base-2/components/columns.tsx b/apps/web/src/components/blocks/data-grid-base-2/components/columns.tsx new file mode 100644 index 0000000..ca3140e --- /dev/null +++ b/apps/web/src/components/blocks/data-grid-base-2/components/columns.tsx @@ -0,0 +1,691 @@ +"use client" +"use no memo" + +import { memo, useMemo, useState } from "react" +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" +import { Badge } from "@/components/reui/badge" +import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header" +import { DataGridTableRowPin } from "@/components/reui/data-grid/data-grid-table" +import { Row, type ColumnDef } from "@tanstack/react-table" +import { toast } from "sonner" + +import { cn } from "@telemt/ui/lib/utils" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@telemt/ui/components/alert-dialog" +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@telemt/ui/components/avatar" +import { Button } from "@telemt/ui/components/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@telemt/ui/components/dropdown-menu" +import { Item, ItemMedia } from "@telemt/ui/components/item" +import { + Progress, + ProgressLabel, + ProgressValue, +} from "@telemt/ui/components/progress" +import { Skeleton } from "@telemt/ui/components/skeleton" +import { + CATEGORY_LABELS, + ContactPriority, + ContactStatus, + IContact, + type CategoryLabel, +} from "./data" +import { MoreHorizontalIcon, PinOffIcon, Pin, EyeIcon, MailIcon, CopyIcon, Trash2Icon } from "lucide-react" + +// ── Category tag colors (light + dark) ── + +const categoryBadgeClass: Record = { + "E-commerce": + "bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300", + Enterprise: + "bg-rose-100 text-rose-800 dark:bg-rose-950/50 dark:text-rose-300", + P2P: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300", + AI: "bg-violet-100 text-violet-800 dark:bg-violet-950/50 dark:text-violet-300", + Digital: "bg-sky-100 text-sky-800 dark:bg-sky-950/50 dark:text-sky-300", + Infrastructure: + "bg-cyan-100 text-cyan-800 dark:bg-cyan-950/50 dark:text-cyan-300", + "Developer tools": + "bg-indigo-100 text-indigo-800 dark:bg-indigo-950/50 dark:text-indigo-300", + Automation: + "bg-yellow-100 text-yellow-800 dark:bg-yellow-950/50 dark:text-yellow-300", +} + +function getCategoryClasses(tag: string): string { + if (CATEGORY_LABELS.includes(tag as CategoryLabel)) { + return categoryBadgeClass[tag as CategoryLabel] + } + return "bg-muted text-muted-foreground" +} + +export const CategoryTags = memo(function CategoryTags({ + tags, +}: { + tags: CategoryLabel[] +}) { + return ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ ) +}) + +// ── Availability dot (aligned with data-grid-1 CustomerCell) ── + +const availabilityColor: Record = { + online: "bg-green-500", + away: "bg-yellow-400", + busy: "bg-red-500", + offline: "bg-gray-500", +} + +// ── Status badge ── + +const statusConfig: Record = { + Active: { dot: "bg-emerald-500" }, + Lead: { dot: "bg-blue-500" }, + Prospect: { dot: "bg-amber-500" }, + Churned: { dot: "bg-muted-foreground" }, +} + +export function StatusBadge({ status }: { status: ContactStatus }) { + return ( + + + {status} + + ) +} + +// ── Priority badge ── + +const priorityConfig: Record< + ContactPriority, + { variant: React.ComponentProps["variant"] } +> = { + High: { variant: "destructive-light" }, + Medium: { variant: "warning-light" }, + Low: { variant: "secondary" }, +} + +export function PriorityBadge({ priority }: { priority: ContactPriority }) { + return {priority} +} + +// ── Score (shadcn Progress) ── + +function ScoreCell({ score }: { score: number }) { + const indicatorClass = + score >= 75 + ? "**:data-[slot=progress-indicator]:bg-emerald-500" + : score >= 40 + ? "**:data-[slot=progress-indicator]:bg-amber-500" + : "**:data-[slot=progress-indicator]:bg-red-500" + return ( + + Lead score + + {(_, value) => `${value ?? score}%`} + + + ) +} + +// ── Stock sparkline (thin smooth curve; green = up, red = down vs series start) ── + +// Smooth the polyline into a flowing cubic-bezier path: each segment's control +// points follow the slope of the neighbouring points (Catmull-Rom style). +function buildSmoothLinePath(pts: { x: number; y: number }[]): string { + if (pts.length === 0) return "" + if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}` + + const smoothing = 0.2 + const controlPoint = ( + current: { x: number; y: number }, + previous: { x: number; y: number } | undefined, + next: { x: number; y: number } | undefined, + reverse?: boolean + ) => { + const p = previous ?? current + const n = next ?? current + const angle = Math.atan2(n.y - p.y, n.x - p.x) + (reverse ? Math.PI : 0) + const length = Math.hypot(n.x - p.x, n.y - p.y) * smoothing + return { + x: current.x + Math.cos(angle) * length, + y: current.y + Math.sin(angle) * length, + } + } + + let d = `M ${pts[0].x} ${pts[0].y}` + for (let i = 1; i < pts.length; i++) { + const start = controlPoint(pts[i - 1], pts[i - 2], pts[i]) + const end = controlPoint(pts[i], pts[i - 1], pts[i + 1], true) + d += ` C ${start.x} ${start.y} ${end.x} ${end.y} ${pts[i].x} ${pts[i].y}` + } + return d +} + +function StockSparkline({ data }: { data: number[] }) { + const w = 92 + const h = 26 + const padX = 2 + const padY = 2 + const innerW = w - padX * 2 + const innerH = h - padY * 2 + + const { linePath, strokeClass } = useMemo(() => { + const max = Math.max(...data) + const min = Math.min(...data) + const range = max - min || 1 + const n = data.length + const step = innerW / Math.max(1, n - 1) + + const pts = data.map((v, i) => { + const x = padX + i * step + const y = padY + ((max - v) / range) * innerH + return { x, y } + }) + + const linePath = buildSmoothLinePath(pts) + + const delta = data[data.length - 1] - data[0] + const strokeClass = + delta > 0 + ? "stroke-emerald-600 dark:stroke-emerald-400" + : delta < 0 + ? "stroke-red-600 dark:stroke-red-400" + : "stroke-muted-foreground" + + return { linePath, strokeClass } + }, [data]) + + return ( + + + + ) +} + +// ── Contact cell (same layout as data-grid-1 CustomerCell) ── + +const ContactCell = memo(function ContactCell({ row }: { row: Row }) { + const o = row.original + + return ( +
+
+ + + + {o.name + .split("") + .map((n) => n[0]) + .join("")} + + + +
+
+
{o.name}
+
+ {o.email} +
+
+
+ ) +}) + +// ── Actions cell ── + +export function ActionsCell({ row }: { row: Row }) { + const { copyToClipboard } = useCopyToClipboard() + const [deleteOpen, setDeleteOpen] = useState(false) + const isPinned = row.getIsPinned() + + const handleDeleteConfirm = () => { + setDeleteOpen(false) + toast.message("Delete requested", { + description: `${row.original.name}. Connect your CRM (demo).`, + }) + } + + return ( + <> + + + } + > + + + + row.pin(isPinned ? false : "top")}> + {isPinned ? ( + + + toast.info("View contact", { + description: "Open your detail route (demo).", + }) + } + > + + + toast.message("Compose email", { + description: "Wire to your mailer (demo).", + }) + } + > + + { + copyToClipboard(row.original.email) + toast.success("Email copied", { + description: row.original.email, + }) + }} + > + + + setDeleteOpen(true)} + > + + + + + + + + + Delete contact? + + This will remove{" "} + + {row.original.name} + + {" "} + from the list. Connect your API to persist changes. + + + + Cancel + + Delete + + + + + + ) +} + +// ── Column definitions ── + +export const columns: ColumnDef[] = [ + { + id: "pin", + header: "", + cell: ({ row }) => , + enableSorting: false, + size: 40, + enableResizing: false, + enableHiding: false, + meta: { + skeleton: , + }, + }, + { + accessorKey: "name", + id: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + enableSorting: true, + enableHiding: false, + enableResizing: true, + minSize: 200, + meta: { + autoSize: true, + skeleton: ( +
+ +
+ + +
+
+ ), + }, + }, + { + accessorKey: "company", + id: "company", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ + + {row.original.companyLogo} + + + + {row.original.company} + +
+ ), + size: 140, + enableSorting: true, + enableHiding: true, + enableResizing: true, + meta: { + skeleton: ( +
+ + +
+ ), + }, + }, + { + accessorKey: "jobTitle", + id: "jobTitle", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+
+ {row.original.jobTitle} +
+
+ {row.original.department} +
+
+ ), + size: 160, + enableSorting: true, + enableHiding: true, + enableResizing: true, + meta: { + skeleton: ( +
+ + +
+ ), + }, + }, + { + accessorKey: "location", + id: "location", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ + + {row.original.location} + +
+ ), + size: 150, + enableSorting: true, + enableHiding: true, + enableResizing: true, + meta: { + skeleton: ( +
+ + +
+ ), + }, + }, + { + accessorKey: "tags", + id: "tags", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + size: 220, + enableSorting: false, + enableHiding: true, + enableResizing: true, + meta: { + skeleton: ( +
+ + + +
+ ), + }, + }, + { + accessorKey: "score", + id: "score", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + size: 148, + enableSorting: true, + enableHiding: true, + enableResizing: true, + meta: { + skeleton: ( +
+ + +
+ ), + }, + }, + { + accessorKey: "engagementData", + id: "stock", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + size: 108, + enableSorting: false, + enableHiding: true, + enableResizing: true, + meta: { + skeleton: , + }, + }, + { + accessorKey: "revenue", + id: "revenue", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + $ + {row.original.revenue.toLocaleString("en-US", { + minimumFractionDigits: 2, + })} + + ), + size: 110, + enableSorting: true, + enableHiding: true, + enableResizing: true, + meta: { + skeleton: , + }, + }, + { + accessorKey: "lastContact", + id: "lastContact", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.lastContact} + + ), + size: 130, + enableSorting: true, + enableHiding: true, + enableResizing: true, + meta: { + skeleton: , + }, + }, + { + accessorKey: "priority", + id: "priority", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + size: 90, + enableSorting: true, + enableHiding: true, + enableResizing: true, + meta: { + skeleton: , + }, + }, + { + accessorKey: "status", + id: "status", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + size: 110, + enableSorting: true, + enableHiding: true, + enableResizing: true, + meta: { + skeleton: , + }, + }, + { + id: "actions", + header: "", + cell: ({ row }) => , + size: 60, + enableSorting: false, + enableHiding: false, + enableResizing: false, + meta: { + skeleton: , + }, + }, +] \ No newline at end of file diff --git a/apps/web/src/components/blocks/data-grid-base-2/components/data-grid-view.tsx b/apps/web/src/components/blocks/data-grid-base-2/components/data-grid-view.tsx new file mode 100644 index 0000000..695f326 --- /dev/null +++ b/apps/web/src/components/blocks/data-grid-base-2/components/data-grid-view.tsx @@ -0,0 +1,699 @@ +"use no memo" + +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { Badge } from "@/components/reui/badge" +import { DataGrid } from "@/components/reui/data-grid/data-grid" +import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination" +import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area" +import { DataGridTable } from "@/components/reui/data-grid/data-grid-table" +import { + createFilter, + Filters, + type Filter, + type FilterFieldConfig, +} from "@/components/reui/filters" +import { + Frame, + FrameDescription, + FrameFooter, + FrameHeader, + FramePanel, + FrameTitle, +} from "@/components/reui/frame" +import { + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + PaginationState, + RowPinningState, + SortingState, + useReactTable, + type VisibilityState, +} from "@tanstack/react-table" +import { toast } from "sonner" + +import { cn } from "@telemt/ui/lib/utils" +import { Button } from "@telemt/ui/components/button" +import { + ButtonGroup, + ButtonGroupText, +} from "@telemt/ui/components/button-group" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@telemt/ui/components/dropdown-menu" +import { Item, ItemMedia } from "@telemt/ui/components/item" +import { Separator } from "@telemt/ui/components/separator" +import { TooltipProvider } from "@telemt/ui/components/tooltip" +import { CategoryTags, columns, PriorityBadge, StatusBadge } from "./columns" +import { + CATEGORY_LABELS, + CONTACTS, + type CategoryLabel, + type ContactPriority, + type ContactStage, + type ContactStatus, + type IContact, +} from "./data" +import { UserIcon, MailIcon, Building2Icon, MapPinIcon, CircleDotIcon, FlagIcon, GitBranchIcon, TagIcon, UserPlusIcon, FilterIcon, PinOffIcon, FunnelXIcon, MoreHorizontalIcon, FileDownIcon, SettingsIcon } from "lucide-react" + +// ── Helpers ── + +function getActiveFilters(filters: Filter[]) { + return filters.filter((filter) => { + const { values } = filter + if (!values || values.length === 0) return false + if ( + values.every((value) => typeof value === "string" && value.trim() === "") + ) + return false + if (values.every((value) => value === null || value === undefined)) + return false + if (values.every((value) => Array.isArray(value) && value.length === 0)) + return false + return true + }) +} + +function serializeActiveFiltersKey(active: Filter[]) { + return JSON.stringify( + active.map((f) => ({ + field: f.field, + operator: f.operator, + values: f.values, + })) + ) +} + +function filterFieldValue(item: IContact, field: string): unknown { + if (field === "tags") return item.tags.join(" ") + return item[field as keyof IContact] +} + +function applyFiltersToData(data: IContact[], filters: Filter[]): IContact[] { + const active = getActiveFilters(filters) + let result = [...data] + active.forEach((filter) => { + const { field, operator, values } = filter + result = result.filter((item) => { + if (field === "tags") { + const selected = values.map(String) + switch (operator) { + case "is": + return ( + selected.length > 0 && + item.tags.includes(selected[0] as CategoryLabel) + ) + case "is_not": + return !selected.some((v) => item.tags.includes(v as CategoryLabel)) + case "is_any_of": + return selected.some((v) => item.tags.includes(v as CategoryLabel)) + case "is_not_any_of": + return !selected.some((v) => item.tags.includes(v as CategoryLabel)) + case "contains": { + const tokens = values.map((v) => String(v).trim()).filter(Boolean) + if (tokens.length === 0) return true + return tokens.some((token) => + item.tags.some((t) => + t.toLowerCase().includes(token.toLowerCase()) + ) + ) + } + case "not_contains": + return !values.some((v) => + item.tags.some((t) => + t.toLowerCase().includes(String(v).toLowerCase()) + ) + ) + default: + return true + } + } + + const raw = filterFieldValue(item, field) + const fieldValue = raw != null ? raw : "" + + switch (operator) { + case "is": + return values.includes(fieldValue) + case "is_not": + return !values.includes(fieldValue) + case "is_any_of": + return values.some((v) => fieldValue === v) + case "is_not_any_of": + return !values.some((v) => fieldValue === v) + case "contains": { + const tokens = values.map((v) => String(v).trim()).filter(Boolean) + if (tokens.length === 0) return true + return tokens.some((token) => + String(fieldValue).toLowerCase().includes(token.toLowerCase()) + ) + } + case "not_contains": + return !values.some((v) => + String(fieldValue).toLowerCase().includes(String(v).toLowerCase()) + ) + case "starts_with": + return values.some((v) => + String(fieldValue).toLowerCase().startsWith(String(v).toLowerCase()) + ) + case "ends_with": + return values.some((v) => + String(fieldValue).toLowerCase().endsWith(String(v).toLowerCase()) + ) + case "equals": + return fieldValue === values[0] + case "not_equals": + return fieldValue !== values[0] + case "greater_than": + return Number(fieldValue) > Number(values[0]) + case "less_than": + return Number(fieldValue) < Number(values[0]) + case "greater_than_or_equal": + return Number(fieldValue) >= Number(values[0]) + case "less_than_or_equal": + return Number(fieldValue) <= Number(values[0]) + case "between": + if (values.length >= 2) { + const min = Number(values[0]) + const max = Number(values[1]) + return Number(fieldValue) >= min && Number(fieldValue) <= max + } + return true + case "not_between": + if (values.length >= 2) { + const min = Number(values[0]) + const max = Number(values[1]) + return Number(fieldValue) < min || Number(fieldValue) > max + } + return true + case "empty": + return fieldValue === "" || fieldValue == null + case "not_empty": + return fieldValue !== "" && fieldValue != null + default: + return true + } + }) + }) + return result +} + +const STATUS_OPTIONS: { value: ContactStatus; label: string }[] = [ + { value: "Active", label: "Active" }, + { value: "Lead", label: "Lead" }, + { value: "Prospect", label: "Prospect" }, + { value: "Churned", label: "Churned" }, +] + +const PRIORITY_OPTIONS: { value: ContactPriority; label: string }[] = [ + { value: "High", label: "High" }, + { value: "Medium", label: "Medium" }, + { value: "Low", label: "Low" }, +] + +const STAGE_OPTIONS: { value: ContactStage; label: string }[] = [ + { value: "Awareness", label: "Awareness" }, + { value: "Consideration", label: "Consideration" }, + { value: "Decision", label: "Decision" }, + { value: "Retention", label: "Retention" }, +] + +const stageToneClass: Record = { + Awareness: "bg-sky-500", + Consideration: "bg-amber-500", + Decision: "bg-emerald-500", + Retention: "bg-violet-500", +} + +function renderSelectedCount(values: unknown[]) { + if (values.length === 0) return "Select..." + if (values.length > 1) return `${values.length} selected` + return null +} + +function createDefaultContactFilters(): Filter[] { + return [createFilter("name", "contains", [""])] +} + +// ── Main ── + +export function ContactsGridView() { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 5, + }) + const [sorting, setSorting] = useState([ + { id: "name", desc: false }, + ]) + const [columnVisibility, setColumnVisibility] = useState({ + jobTitle: false, + location: false, + priority: false, + lastContact: false, + }) + const [rowPinning, setRowPinning] = useState({ + top: ["1", "2"], + bottom: [], + }) + const [filters, setFilters] = useState(createDefaultContactFilters) + + const [isLoading, setIsLoading] = useState(false) + const [filteredData, setFilteredData] = useState(CONTACTS) + const isInitialLoad = useRef(true) + const lastAppliedActiveKey = useRef( + serializeActiveFiltersKey(getActiveFilters(createDefaultContactFilters())) + ) + + const resolvedRowPinning = useMemo(() => { + const availableIds = new Set(filteredData.map((contact) => contact.id)) + + return { + top: (rowPinning.top ?? []).filter((id) => availableIds.has(id)), + bottom: (rowPinning.bottom ?? []).filter((id) => availableIds.has(id)), + } + }, [filteredData, rowPinning.bottom, rowPinning.top]) + + const companyOptions = useMemo(() => { + return [ + ...new Map( + CONTACTS.map((contact) => [contact.company, contact]) + ).values(), + ] + .sort((a, b) => a.company.localeCompare(b.company)) + .map((contact) => ({ + value: contact.company, + label: contact.company, + icon: ( + } + className="w-auto shrink-0 border-0 p-0 [&_svg]:size-4" + > + + {contact.companyLogo} + + + ), + })) + }, []) + + const filterFields: FilterFieldConfig[] = useMemo( + () => [ + { + key: "name", + label: "Name", + icon: ( + + ), + type: "text", + className: "w-40", + placeholder: "Search...", + }, + { + key: "email", + label: "Email", + icon: ( + + ), + type: "text", + className: "w-48", + placeholder: "Search...", + }, + { + key: "company", + label: "Company", + icon: ( + + ), + type: "select", + searchable: true, + className: "w-[180px]", + options: companyOptions, + customValueRenderer: (values, options) => { + const state = renderSelectedCount(values) + if (state) return state + + const option = options.find((item) => item.value === values[0]) + if (!option) return String(values[0]) + + return ( +
+ {option.icon} + {option.label} +
+ ) + }, + }, + { + key: "location", + label: "Location", + icon: ( + + ), + type: "text", + className: "w-44", + placeholder: "City / region...", + }, + { + key: "status", + label: "Status", + icon: ( + + ), + type: "select", + searchable: false, + className: "w-[140px]", + options: STATUS_OPTIONS, + customValueRenderer: (values) => { + const state = renderSelectedCount(values) + if (state) return state + + return + }, + }, + { + key: "priority", + label: "Priority", + icon: ( + + ), + type: "select", + searchable: false, + className: "w-[120px]", + options: PRIORITY_OPTIONS, + customValueRenderer: (values) => { + const state = renderSelectedCount(values) + if (state) return state + + return + }, + }, + { + key: "stage", + label: "Stage", + icon: ( + + ), + type: "select", + searchable: false, + className: "w-[150px]", + options: STAGE_OPTIONS.map((stage) => ({ + ...stage, + icon: ( +