From 303d1847fce561015dd64568bcaa7166bbb49987 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:12:50 +0000 Subject: [PATCH 1/3] Initial plan From 5af3b6f7096ff4801c011d35a34f9741768ed6c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:20:22 +0000 Subject: [PATCH 2/3] Add Freshdesk API integration for IT tickets (Hardware Request, Problem types) Co-authored-by: LucaGerlich <39236248+LucaGerlich@users.noreply.github.com> --- .env.example | 11 + .../admin/settings/ui/AdminSettingsPage.tsx | 12 +- .../settings/ui/FreshdeskSettingsTab.tsx | 281 ++++++++++++++ src/app/api/admin/settings/freshdesk/route.ts | 119 ++++++ .../admin/settings/freshdesk/test/route.ts | 55 +++ src/app/api/tickets/[id]/route.ts | 72 ++++ src/app/api/tickets/route.ts | 72 ++++ src/app/tickets/page.tsx | 30 ++ src/app/tickets/ui/TicketsPageClient.tsx | 358 ++++++++++++++++++ src/components/Sidebar.tsx | 7 + src/lib/freshdesk/client.ts | 148 ++++++++ src/lib/freshdesk/index.ts | 7 + src/lib/freshdesk/types.ts | 209 ++++++++++ 13 files changed, 1380 insertions(+), 1 deletion(-) create mode 100644 src/app/admin/settings/ui/FreshdeskSettingsTab.tsx create mode 100644 src/app/api/admin/settings/freshdesk/route.ts create mode 100644 src/app/api/admin/settings/freshdesk/test/route.ts create mode 100644 src/app/api/tickets/[id]/route.ts create mode 100644 src/app/api/tickets/route.ts create mode 100644 src/app/tickets/page.tsx create mode 100644 src/app/tickets/ui/TicketsPageClient.tsx create mode 100644 src/lib/freshdesk/client.ts create mode 100644 src/lib/freshdesk/index.ts create mode 100644 src/lib/freshdesk/types.ts diff --git a/.env.example b/.env.example index d3315d9..7739772 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,14 @@ NEXTAUTH_SECRET=your-super-secret-key-here-generate-with-openssl-rand-base64-32 # S3_REGION=us-east-1 # S3_ACCESS_KEY=your_access_key # S3_SECRET_KEY=your_secret_key + +# ============================================================================== +# Freshdesk Integration (Optional - for IT Tickets) +# Configure via Admin Settings UI or environment variables +# ============================================================================== + +# Freshdesk domain (e.g., "yourcompany" for yourcompany.freshdesk.com) +# FRESHDESK_DOMAIN=yourcompany + +# Freshdesk API key (found in Profile Settings > API Key) +# FRESHDESK_API_KEY=your_freshdesk_api_key diff --git a/src/app/admin/settings/ui/AdminSettingsPage.tsx b/src/app/admin/settings/ui/AdminSettingsPage.tsx index af36ba9..af6bce2 100644 --- a/src/app/admin/settings/ui/AdminSettingsPage.tsx +++ b/src/app/admin/settings/ui/AdminSettingsPage.tsx @@ -12,6 +12,7 @@ import { FileText, Bell, Shield, + Ticket, } from "lucide-react"; import EmailSettingsTab from "./EmailSettingsTab"; import UsersSettingsTab from "./UsersSettingsTab"; @@ -20,6 +21,7 @@ import DepreciationSettingsTab from "./DepreciationSettingsTab"; import CustomFieldsTab from "./CustomFieldsTab"; import NotificationSettingsTab from "./NotificationSettingsTab"; import GeneralSettingsTab from "./GeneralSettingsTab"; +import FreshdeskSettingsTab from "./FreshdeskSettingsTab"; interface AdminSettingsPageProps { settings: Record< @@ -101,7 +103,7 @@ export default function AdminSettingsPage({ - + General @@ -110,6 +112,10 @@ export default function AdminSettingsPage({ Email + + + Freshdesk + Notifications @@ -141,6 +147,10 @@ export default function AdminSettingsPage({ + + + + diff --git a/src/app/admin/settings/ui/FreshdeskSettingsTab.tsx b/src/app/admin/settings/ui/FreshdeskSettingsTab.tsx new file mode 100644 index 0000000..725b8b6 --- /dev/null +++ b/src/app/admin/settings/ui/FreshdeskSettingsTab.tsx @@ -0,0 +1,281 @@ +"use client"; + +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { + CheckCircle, + XCircle, + Loader2, + Eye, + EyeOff, + ExternalLink, + Ticket, +} from "lucide-react"; + +interface FreshdeskSettingsTabProps { + settings: Array<{ + id: string; + key: string; + value: string | null; + type: string; + description: string | null; + isEncrypted: boolean; + }>; +} + +export default function FreshdeskSettingsTab({ settings }: FreshdeskSettingsTabProps) { + const getSettingValue = (key: string) => + settings.find((s) => s.key === key)?.value || ""; + + const [domain, setDomain] = useState(getSettingValue("freshdesk_domain") || ""); + const [apiKey, setApiKey] = useState( + getSettingValue("freshdesk_api_key") ? "********" : "" + ); + const [showApiKey, setShowApiKey] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [connectionStatus, setConnectionStatus] = useState< + "unknown" | "connected" | "failed" + >("unknown"); + + const handleSave = async () => { + if (!domain) { + toast.error("Freshdesk domain is required"); + return; + } + + setIsSaving(true); + try { + const response = await fetch("/api/admin/settings/freshdesk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + domain, + apiKey: apiKey !== "********" ? apiKey : undefined, + }), + }); + + if (response.ok) { + toast.success("Freshdesk settings saved successfully"); + // Mark API key as saved + if (apiKey && apiKey !== "********") { + setApiKey("********"); + } + } else { + const error = await response.json(); + toast.error(error.error || "Failed to save settings"); + } + } catch { + toast.error("Failed to save settings"); + } finally { + setIsSaving(false); + } + }; + + const handleTestConnection = async () => { + if (!domain) { + toast.error("Please enter your Freshdesk domain first"); + return; + } + + setIsTesting(true); + setConnectionStatus("unknown"); + try { + const response = await fetch("/api/admin/settings/freshdesk/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + domain, + apiKey: apiKey !== "********" ? apiKey : "********", + }), + }); + + const result = await response.json(); + if (result.success) { + setConnectionStatus("connected"); + toast.success("Successfully connected to Freshdesk!"); + } else { + setConnectionStatus("failed"); + toast.error(result.error || "Connection test failed"); + } + } catch { + setConnectionStatus("failed"); + toast.error("Connection test failed"); + } finally { + setIsTesting(false); + } + }; + + return ( +
+ + + + + + Freshdesk Integration + + {connectionStatus === "connected" && ( + + + Connected + + )} + {connectionStatus === "failed" && ( + + + Connection Failed + + )} + + + Connect to Freshdesk to view IT support tickets for Hardware Requests + and Problems + + + +
+
+ +
+ https:// + setDomain(e.target.value)} + placeholder="yourcompany" + className="flex-1" + /> + + .freshdesk.com + +
+

+ Enter your Freshdesk subdomain (e.g., "yourcompany" from + yourcompany.freshdesk.com) +

+
+ +
+ +
+ setApiKey(e.target.value)} + placeholder="Enter your Freshdesk API key" + className="pr-10" + /> + +
+

+ Find your API key in Freshdesk under Profile Settings > API Key +

+
+
+ +
+ + +
+
+
+ + + + Ticket Types + + The following Freshdesk ticket types will be synced and displayed in + your IT Tickets section + + + +
+ Hardware Request + Problem +
+

+ Tickets with these types will appear in the IT Tickets page. Make sure + these ticket types are configured in your Freshdesk admin settings. +

+
+
+ + + + Setup Guide + How to get your Freshdesk API key + + +
    +
  1. Log in to your Freshdesk account
  2. +
  3. Click on your profile picture in the top right
  4. +
  5. Select "Profile Settings"
  6. +
  7. Scroll down to find your API Key on the right side
  8. +
  9. Copy the API key and paste it above
  10. +
+ + + Your API key is stored securely and encrypted. It will only be used + to fetch ticket data from Freshdesk. + + + +
+
+
+ ); +} diff --git a/src/app/api/admin/settings/freshdesk/route.ts b/src/app/api/admin/settings/freshdesk/route.ts new file mode 100644 index 0000000..6686873 --- /dev/null +++ b/src/app/api/admin/settings/freshdesk/route.ts @@ -0,0 +1,119 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import prisma from "@/lib/prisma"; +import { createFreshdeskClient } from "@/lib/freshdesk"; + +/** + * GET /api/admin/settings/freshdesk + * Get current Freshdesk configuration (without exposing API key) + */ +export async function GET() { + try { + const session = await auth(); + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const settings = await prisma.systemSettings.findMany({ + where: { + settingKey: { + startsWith: "freshdesk_", + }, + }, + }); + + const config: Record = {}; + for (const setting of settings) { + const key = setting.settingKey.replace("freshdesk_", ""); + // Mask API key for security + if (key === "api_key" && setting.settingValue) { + config[key] = "********"; + } else { + config[key] = setting.settingValue; + } + } + + return NextResponse.json({ config }); + } catch (error) { + console.error("GET /api/admin/settings/freshdesk error:", error); + return NextResponse.json( + { error: "Failed to get Freshdesk settings" }, + { status: 500 } + ); + } +} + +/** + * POST /api/admin/settings/freshdesk + * Save Freshdesk configuration + */ +export async function POST(req: Request) { + try { + const session = await auth(); + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json(); + const { domain, apiKey } = body; + + if (!domain) { + return NextResponse.json( + { error: "Freshdesk domain is required" }, + { status: 400 } + ); + } + + // Upsert Freshdesk settings + const settingsToUpsert = [ + { + key: "freshdesk_domain", + value: domain, + type: "string", + category: "freshdesk", + description: "Freshdesk subdomain (e.g., 'yourcompany' for yourcompany.freshdesk.com)", + isEncrypted: false, + }, + ]; + + // Only update API key if provided (not masked value) + if (apiKey && apiKey !== "********") { + settingsToUpsert.push({ + key: "freshdesk_api_key", + value: apiKey, + type: "string", + category: "freshdesk", + description: "Freshdesk API key for authentication", + isEncrypted: true, + }); + } + + for (const setting of settingsToUpsert) { + await prisma.systemSettings.upsert({ + where: { settingKey: setting.key }, + update: { + settingValue: setting.value, + updatedAt: new Date(), + }, + create: { + settingKey: setting.key, + settingValue: setting.value, + settingType: setting.type, + category: setting.category, + description: setting.description, + isEncrypted: setting.isEncrypted, + }, + }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("POST /api/admin/settings/freshdesk error:", error); + return NextResponse.json( + { error: "Failed to save Freshdesk settings" }, + { status: 500 } + ); + } +} + +export const dynamic = "force-dynamic"; diff --git a/src/app/api/admin/settings/freshdesk/test/route.ts b/src/app/api/admin/settings/freshdesk/test/route.ts new file mode 100644 index 0000000..b6328af --- /dev/null +++ b/src/app/api/admin/settings/freshdesk/test/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import prisma from "@/lib/prisma"; +import { createFreshdeskClient } from "@/lib/freshdesk"; + +/** + * POST /api/admin/settings/freshdesk/test + * Test Freshdesk API connection + */ +export async function POST(req: Request) { + try { + const session = await auth(); + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json(); + let { domain, apiKey } = body; + + // If API key is masked, get the stored one + if (apiKey === "********" || !apiKey) { + const storedApiKey = await prisma.systemSettings.findUnique({ + where: { settingKey: "freshdesk_api_key" }, + }); + apiKey = storedApiKey?.settingValue; + } + + if (!domain || !apiKey) { + return NextResponse.json( + { error: "Freshdesk domain and API key are required" }, + { status: 400 } + ); + } + + const client = createFreshdeskClient({ domain, apiKey }); + const isConnected = await client.testConnection(); + + if (isConnected) { + return NextResponse.json({ success: true, message: "Connection successful" }); + } else { + return NextResponse.json( + { success: false, error: "Failed to connect to Freshdesk. Please check your credentials." }, + { status: 400 } + ); + } + } catch (error) { + console.error("POST /api/admin/settings/freshdesk/test error:", error); + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : "Connection test failed" }, + { status: 500 } + ); + } +} + +export const dynamic = "force-dynamic"; diff --git a/src/app/api/tickets/[id]/route.ts b/src/app/api/tickets/[id]/route.ts new file mode 100644 index 0000000..7d017d5 --- /dev/null +++ b/src/app/api/tickets/[id]/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import prisma from "@/lib/prisma"; +import { createFreshdeskClient } from "@/lib/freshdesk"; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +/** + * GET /api/tickets/[id] + * Fetch a single ticket from Freshdesk + */ +export async function GET(req: Request, { params }: RouteParams) { + try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const ticketId = parseInt(id, 10); + + if (isNaN(ticketId)) { + return NextResponse.json( + { error: "Invalid ticket ID" }, + { status: 400 } + ); + } + + // Get Freshdesk configuration from database + const [domainSetting, apiKeySetting] = await Promise.all([ + prisma.systemSettings.findUnique({ + where: { settingKey: "freshdesk_domain" }, + }), + prisma.systemSettings.findUnique({ + where: { settingKey: "freshdesk_api_key" }, + }), + ]); + + if (!domainSetting?.settingValue || !apiKeySetting?.settingValue) { + return NextResponse.json( + { error: "Freshdesk is not configured. Please configure it in Admin Settings." }, + { status: 400 } + ); + } + + const client = createFreshdeskClient({ + domain: domainSetting.settingValue, + apiKey: apiKeySetting.settingValue, + }); + + const result = await client.getTicket(ticketId); + + if (!result.success) { + return NextResponse.json( + { error: result.error || "Failed to fetch ticket" }, + { status: 500 } + ); + } + + return NextResponse.json({ ticket: result.data }); + } catch (error) { + console.error("GET /api/tickets/[id] error:", error); + return NextResponse.json( + { error: "Failed to fetch ticket" }, + { status: 500 } + ); + } +} + +export const dynamic = "force-dynamic"; diff --git a/src/app/api/tickets/route.ts b/src/app/api/tickets/route.ts new file mode 100644 index 0000000..7879931 --- /dev/null +++ b/src/app/api/tickets/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import prisma from "@/lib/prisma"; +import { createFreshdeskClient, SUPPORTED_TICKET_TYPES } from "@/lib/freshdesk"; + +/** + * GET /api/tickets + * Fetch tickets from Freshdesk (filtered to Hardware Request and Problem types) + */ +export async function GET(req: Request) { + try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Get Freshdesk configuration from database + const [domainSetting, apiKeySetting] = await Promise.all([ + prisma.systemSettings.findUnique({ + where: { settingKey: "freshdesk_domain" }, + }), + prisma.systemSettings.findUnique({ + where: { settingKey: "freshdesk_api_key" }, + }), + ]); + + if (!domainSetting?.settingValue || !apiKeySetting?.settingValue) { + return NextResponse.json( + { error: "Freshdesk is not configured. Please configure it in Admin Settings." }, + { status: 400 } + ); + } + + // Parse query params + const url = new URL(req.url); + const page = parseInt(url.searchParams.get("page") || "1", 10); + const typeFilter = url.searchParams.get("type"); // Optional: filter to specific type + + const client = createFreshdeskClient({ + domain: domainSetting.settingValue, + apiKey: apiKeySetting.settingValue, + }); + + // Determine which types to filter + const types = typeFilter + ? [typeFilter] + : [...SUPPORTED_TICKET_TYPES]; + + const result = await client.getTicketsByTypes(types, page); + + if (!result.success) { + return NextResponse.json( + { error: result.error || "Failed to fetch tickets" }, + { status: 500 } + ); + } + + return NextResponse.json({ + tickets: result.data || [], + page, + types: SUPPORTED_TICKET_TYPES, + }); + } catch (error) { + console.error("GET /api/tickets error:", error); + return NextResponse.json( + { error: "Failed to fetch tickets" }, + { status: 500 } + ); + } +} + +export const dynamic = "force-dynamic"; diff --git a/src/app/tickets/page.tsx b/src/app/tickets/page.tsx new file mode 100644 index 0000000..720997e --- /dev/null +++ b/src/app/tickets/page.tsx @@ -0,0 +1,30 @@ +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import Breadcrumb from "@/components/Breadcrumb"; +import TicketsPageClient from "./ui/TicketsPageClient"; + +export const metadata = { + title: "IT Tickets - Asset Tracker", + description: "View IT support tickets from Freshdesk", +}; + +export default async function Page() { + const session = await auth(); + + // Require authentication + if (!session?.user) { + redirect("/login"); + } + + const breadcrumbOptions = [ + { label: "Home", href: "/" }, + { label: "IT Tickets", href: "/tickets" }, + ]; + + return ( + <> + + + + ); +} diff --git a/src/app/tickets/ui/TicketsPageClient.tsx b/src/app/tickets/ui/TicketsPageClient.tsx new file mode 100644 index 0000000..2e8840e --- /dev/null +++ b/src/app/tickets/ui/TicketsPageClient.tsx @@ -0,0 +1,358 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { toast } from "sonner"; +import { + Loader2, + RefreshCw, + Search, + Ticket, + AlertCircle, + ExternalLink, + Settings, +} from "lucide-react"; +import Link from "next/link"; +import { + type FreshdeskTicket, + FreshdeskTicketStatus, + FreshdeskTicketPriority, + getStatusLabel, + getPriorityLabel, + getStatusColor, + getPriorityColor, + SUPPORTED_TICKET_TYPES, +} from "@/lib/freshdesk"; + +interface TicketsPageClientProps { + isAdmin: boolean; +} + +export default function TicketsPageClient({ isAdmin }: TicketsPageClientProps) { + const [tickets, setTickets] = useState([]); + const [filteredTickets, setFilteredTickets] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); + const [priorityFilter, setPriorityFilter] = useState("all"); + + const fetchTickets = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const response = await fetch("/api/tickets"); + const data = await response.json(); + + if (!response.ok) { + setError(data.error || "Failed to fetch tickets"); + setTickets([]); + return; + } + + setTickets(data.tickets || []); + } catch { + setError("Failed to connect to the server"); + setTickets([]); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchTickets(); + }, [fetchTickets]); + + // Apply filters + useEffect(() => { + let result = [...tickets]; + + // Search filter + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter( + (ticket) => + ticket.subject.toLowerCase().includes(query) || + ticket.description_text?.toLowerCase().includes(query) || + String(ticket.id).includes(query) + ); + } + + // Status filter + if (statusFilter !== "all") { + result = result.filter( + (ticket) => ticket.status === parseInt(statusFilter, 10) + ); + } + + // Type filter + if (typeFilter !== "all") { + result = result.filter((ticket) => ticket.type === typeFilter); + } + + // Priority filter + if (priorityFilter !== "all") { + result = result.filter( + (ticket) => ticket.priority === parseInt(priorityFilter, 10) + ); + } + + setFilteredTickets(result); + }, [tickets, searchQuery, statusFilter, typeFilter, priorityFilter]); + + const handleRefresh = () => { + toast.info("Refreshing tickets..."); + fetchTickets(); + }; + + if (error) { + return ( +
+
+
+

+ + IT Tickets +

+

+ Hardware Requests and Problems from Freshdesk +

+
+
+ + + + Unable to load tickets + +

{error}

+ {isAdmin && error.includes("not configured") && ( + + )} +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ + IT Tickets +

+

+ Hardware Requests and Problems from Freshdesk +

+
+
+ + {isAdmin && ( + + )} +
+
+ + {/* Filters */} + + +
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+
+ + + + + +
+
+
+
+ + {/* Tickets List */} + {isLoading ? ( +
+ +
+ ) : filteredTickets.length === 0 ? ( + + + +

No tickets found

+

+ {tickets.length === 0 + ? "There are no Hardware Request or Problem tickets in Freshdesk" + : "Try adjusting your filters"} +

+
+
+ ) : ( +
+

+ Showing {filteredTickets.length} of {tickets.length} tickets +

+ {filteredTickets.map((ticket) => ( + + ))} +
+ )} +
+ ); +} + +interface TicketCardProps { + ticket: FreshdeskTicket; +} + +function TicketCard({ ticket }: TicketCardProps) { + const createdDate = new Date(ticket.created_at).toLocaleDateString(); + const updatedDate = new Date(ticket.updated_at).toLocaleDateString(); + + return ( + + +
+
+ + #{ticket.id} + {ticket.subject} + + + {ticket.type && ( + + {ticket.type} + + )} + Created: {createdDate} + Updated: {updatedDate} + +
+
+ + {getStatusLabel(ticket.status)} + + + {getPriorityLabel(ticket.priority)} + +
+
+
+ + +

+ {ticket.description_text || "No description provided"} +

+ {ticket.tags && ticket.tags.length > 0 && ( +
+ {ticket.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 09a5369..0150b49 100755 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -22,6 +22,7 @@ import { BadgeCheck, PanelLeftClose, PanelRightOpen, + Ticket, } from "lucide-react"; import { PlusIcon as SidebarPlusIcon } from "../ui/Icons"; @@ -45,6 +46,12 @@ const navSections = [ { label: "Locations", href: "/locations", icon: MapPin }, ], }, + { + title: "Support", + items: [ + { label: "IT Tickets", href: "/tickets", icon: Ticket }, + ], + }, { title: "Administration", items: [ diff --git a/src/lib/freshdesk/client.ts b/src/lib/freshdesk/client.ts new file mode 100644 index 0000000..1be5ccb --- /dev/null +++ b/src/lib/freshdesk/client.ts @@ -0,0 +1,148 @@ +/** + * Freshdesk API Client + * Handles communication with the Freshdesk API + * Documentation: https://developers.freshdesk.com/api/ + */ + +import type { + FreshdeskConfig, + FreshdeskTicket, + FreshdeskTicketFilters, + FreshdeskApiResult, + FreshdeskTicketType, +} from './types'; +import { SUPPORTED_TICKET_TYPES } from './types'; + +/** + * Freshdesk API Client + */ +export class FreshdeskClient { + private baseUrl: string; + private authHeader: string; + + constructor(config: FreshdeskConfig) { + // Freshdesk domain can be just the subdomain or full URL + const domain = config.domain.replace(/\.freshdesk\.com\/?$/, '').replace(/^https?:\/\//, ''); + this.baseUrl = `https://${domain}.freshdesk.com/api/v2`; + // Freshdesk uses API key as username with 'X' as password for Basic Auth + this.authHeader = `Basic ${Buffer.from(`${config.apiKey}:X`).toString('base64')}`; + } + + /** + * Make an authenticated request to the Freshdesk API + */ + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise> { + try { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + ...options, + headers: { + 'Authorization': this.authHeader, + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = errorData.description || errorData.message || `HTTP ${response.status}`; + return { success: false, error: errorMessage }; + } + + const data = await response.json(); + return { success: true, data }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + /** + * Test the API connection + */ + async testConnection(): Promise { + // Try to fetch the first page of tickets with a limit of 1 to minimize data transfer + const result = await this.request('/tickets?per_page=1'); + return result.success; + } + + /** + * Get all tickets with optional filters + */ + async getTickets(filters?: FreshdeskTicketFilters): Promise> { + const params = new URLSearchParams(); + + if (filters?.page) params.append('page', String(filters.page)); + if (filters?.per_page) params.append('per_page', String(filters.per_page)); + if (filters?.updated_since) params.append('updated_since', filters.updated_since); + + // Note: Freshdesk filter API uses a different endpoint for complex queries + // For simple listing, we'll fetch and filter client-side for type filtering + const queryString = params.toString(); + const endpoint = `/tickets${queryString ? `?${queryString}` : ''}`; + + return this.request(endpoint); + } + + /** + * Get tickets filtered by specific types (Hardware Request, Problem) + * Freshdesk doesn't support type filtering in the list API, so we need to use the search API + */ + async getTicketsByTypes( + types: FreshdeskTicketType[] = [...SUPPORTED_TICKET_TYPES], + page: number = 1, + perPage: number = 30 + ): Promise> { + // Use Freshdesk search API for type filtering + // Search query format: "type:'Hardware Request' OR type:'Problem'" + const typeQueries = types.map(t => `type:'${t}'`).join(' OR '); + const query = encodeURIComponent(`(${typeQueries})`); + + const result = await this.request<{ results: FreshdeskTicket[]; total: number }>( + `/search/tickets?query="${query}"&page=${page}` + ); + + if (result.success && result.data) { + return { success: true, data: result.data.results }; + } + + // If search fails (e.g., search not enabled), fall back to fetching all and filtering + const allTickets = await this.getTickets({ page, per_page: perPage }); + if (allTickets.success && allTickets.data) { + const filtered = allTickets.data.filter( + ticket => ticket.type && types.includes(ticket.type) + ); + return { success: true, data: filtered }; + } + + return allTickets; + } + + /** + * Get a single ticket by ID + */ + async getTicket(ticketId: number): Promise> { + return this.request(`/tickets/${ticketId}?include=requester`); + } + + /** + * Get ticket conversations (replies) + */ + async getTicketConversations(ticketId: number): Promise> { + return this.request(`/tickets/${ticketId}/conversations`); + } +} + +/** + * Create a Freshdesk client from configuration + */ +export function createFreshdeskClient(config: FreshdeskConfig): FreshdeskClient { + if (!config.domain) throw new Error('Freshdesk domain is required'); + if (!config.apiKey) throw new Error('Freshdesk API key is required'); + + return new FreshdeskClient(config); +} diff --git a/src/lib/freshdesk/index.ts b/src/lib/freshdesk/index.ts new file mode 100644 index 0000000..681c701 --- /dev/null +++ b/src/lib/freshdesk/index.ts @@ -0,0 +1,7 @@ +/** + * Freshdesk Integration Module + * Re-exports all Freshdesk types and client + */ + +export * from './types'; +export * from './client'; diff --git a/src/lib/freshdesk/types.ts b/src/lib/freshdesk/types.ts new file mode 100644 index 0000000..b26c08c --- /dev/null +++ b/src/lib/freshdesk/types.ts @@ -0,0 +1,209 @@ +/** + * Freshdesk API Types + * Documentation: https://developers.freshdesk.com/api/ + */ + +/** + * Freshdesk ticket status values + */ +export enum FreshdeskTicketStatus { + Open = 2, + Pending = 3, + Resolved = 4, + Closed = 5, +} + +/** + * Freshdesk ticket priority values + */ +export enum FreshdeskTicketPriority { + Low = 1, + Medium = 2, + High = 3, + Urgent = 4, +} + +/** + * Freshdesk ticket source values + */ +export enum FreshdeskTicketSource { + Email = 1, + Portal = 2, + Phone = 3, + Chat = 7, + Mobihelp = 8, + FeedbackWidget = 9, + OutboundEmail = 10, +} + +/** + * Freshdesk ticket type - these are custom strings configured in Freshdesk + * Common types include: "Question", "Incident", "Problem", "Feature Request", "Hardware Request" + */ +export type FreshdeskTicketType = string; + +/** + * Ticket types we care about for hardware/IT support + */ +export const SUPPORTED_TICKET_TYPES = ['Hardware Request', 'Problem'] as const; +export type SupportedTicketType = (typeof SUPPORTED_TICKET_TYPES)[number]; + +/** + * Freshdesk Ticket interface + */ +export interface FreshdeskTicket { + id: number; + subject: string; + description: string; + description_text: string; + status: FreshdeskTicketStatus; + priority: FreshdeskTicketPriority; + source: FreshdeskTicketSource; + type: FreshdeskTicketType | null; + requester_id: number; + responder_id: number | null; + company_id: number | null; + group_id: number | null; + product_id: number | null; + email_config_id: number | null; + fr_due_by: string | null; + due_by: string | null; + is_escalated: boolean; + tags: string[]; + cc_emails: string[]; + fwd_emails: string[]; + reply_cc_emails: string[]; + ticket_cc_emails: string[]; + spam: boolean; + custom_fields: Record; + created_at: string; + updated_at: string; + attachments?: FreshdeskAttachment[]; + requester?: FreshdeskRequester; +} + +/** + * Freshdesk Requester (contact) + */ +export interface FreshdeskRequester { + id: number; + name: string; + email: string; + phone?: string; + mobile?: string; +} + +/** + * Freshdesk Attachment + */ +export interface FreshdeskAttachment { + id: number; + content_type: string; + size: number; + name: string; + attachment_url: string; + created_at: string; + updated_at: string; +} + +/** + * Freshdesk API configuration + */ +export interface FreshdeskConfig { + domain: string; // e.g., "yourcompany" for yourcompany.freshdesk.com + apiKey: string; +} + +/** + * Freshdesk API response for listing tickets + */ +export interface FreshdeskTicketListResponse { + tickets: FreshdeskTicket[]; + total?: number; +} + +/** + * Freshdesk ticket filters for API calls + */ +export interface FreshdeskTicketFilters { + type?: FreshdeskTicketType; + status?: FreshdeskTicketStatus; + priority?: FreshdeskTicketPriority; + requester_id?: number; + company_id?: number; + updated_since?: string; // ISO date string + page?: number; + per_page?: number; +} + +/** + * API result wrapper + */ +export interface FreshdeskApiResult { + success: boolean; + data?: T; + error?: string; +} + +/** + * Helper functions to convert status/priority to display strings + */ +export function getStatusLabel(status: FreshdeskTicketStatus): string { + switch (status) { + case FreshdeskTicketStatus.Open: + return 'Open'; + case FreshdeskTicketStatus.Pending: + return 'Pending'; + case FreshdeskTicketStatus.Resolved: + return 'Resolved'; + case FreshdeskTicketStatus.Closed: + return 'Closed'; + default: + return 'Unknown'; + } +} + +export function getPriorityLabel(priority: FreshdeskTicketPriority): string { + switch (priority) { + case FreshdeskTicketPriority.Low: + return 'Low'; + case FreshdeskTicketPriority.Medium: + return 'Medium'; + case FreshdeskTicketPriority.High: + return 'High'; + case FreshdeskTicketPriority.Urgent: + return 'Urgent'; + default: + return 'Unknown'; + } +} + +export function getStatusColor(status: FreshdeskTicketStatus): string { + switch (status) { + case FreshdeskTicketStatus.Open: + return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300'; + case FreshdeskTicketStatus.Pending: + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300'; + case FreshdeskTicketStatus.Resolved: + return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'; + case FreshdeskTicketStatus.Closed: + return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'; + default: + return 'bg-gray-100 text-gray-800'; + } +} + +export function getPriorityColor(priority: FreshdeskTicketPriority): string { + switch (priority) { + case FreshdeskTicketPriority.Low: + return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'; + case FreshdeskTicketPriority.Medium: + return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300'; + case FreshdeskTicketPriority.High: + return 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300'; + case FreshdeskTicketPriority.Urgent: + return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'; + default: + return 'bg-gray-100 text-gray-800'; + } +} From 06396370127c285356f9919b88eea4588faa4934 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:22:48 +0000 Subject: [PATCH 3/3] Address code review feedback: add aria-labels and clarifying comments Co-authored-by: LucaGerlich <39236248+LucaGerlich@users.noreply.github.com> --- src/app/admin/settings/ui/FreshdeskSettingsTab.tsx | 2 ++ src/app/tickets/ui/TicketsPageClient.tsx | 6 +++--- src/lib/freshdesk/client.ts | 14 +++++++++++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/app/admin/settings/ui/FreshdeskSettingsTab.tsx b/src/app/admin/settings/ui/FreshdeskSettingsTab.tsx index 725b8b6..122a4b7 100644 --- a/src/app/admin/settings/ui/FreshdeskSettingsTab.tsx +++ b/src/app/admin/settings/ui/FreshdeskSettingsTab.tsx @@ -209,6 +209,7 @@ export default function FreshdeskSettingsTab({ settings }: FreshdeskSettingsTabP