mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 11:12:11 +01:00
feat: complete tRPC integration and fix platform UI issues
- Implement comprehensive tRPC setup with type-safe API - Create tRPC routers for dashboard, admin, and auth endpoints - Migrate frontend components to use tRPC client - Fix platform dashboard Settings button functionality - Add platform settings page with profile and security management - Create OpenAI API mocking infrastructure for cost-safe testing - Update tests to work with new tRPC architecture - Sync database schema to fix AIBatchRequest table errors
This commit is contained in:
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import * as countryCoder from "@rapideditor/country-coder";
|
||||
|
||||
@ -22,7 +22,9 @@ interface GeographicMapProps {
|
||||
* Get coordinates for a country using the country-coder library
|
||||
* This automatically extracts coordinates from the country geometry
|
||||
*/
|
||||
function getCoordinatesFromCountryCoder(countryCode: string): [number, number] | undefined {
|
||||
function getCoordinatesFromCountryCoder(
|
||||
countryCode: string
|
||||
): [number, number] | undefined {
|
||||
try {
|
||||
const feature = countryCoder.feature(countryCode);
|
||||
if (!feature?.geometry) {
|
||||
@ -35,7 +37,10 @@ function getCoordinatesFromCountryCoder(countryCode: string): [number, number] |
|
||||
return [lat, lon]; // Leaflet expects [lat, lon]
|
||||
}
|
||||
|
||||
if (feature.geometry.type === "Polygon" && feature.geometry.coordinates?.[0]?.[0]) {
|
||||
if (
|
||||
feature.geometry.type === "Polygon" &&
|
||||
feature.geometry.coordinates?.[0]?.[0]
|
||||
) {
|
||||
// For polygons, calculate centroid from the first ring
|
||||
const coordinates = feature.geometry.coordinates[0];
|
||||
let lat = 0;
|
||||
@ -47,7 +52,10 @@ function getCoordinatesFromCountryCoder(countryCode: string): [number, number] |
|
||||
return [lat / coordinates.length, lon / coordinates.length];
|
||||
}
|
||||
|
||||
if (feature.geometry.type === "MultiPolygon" && feature.geometry.coordinates?.[0]?.[0]?.[0]) {
|
||||
if (
|
||||
feature.geometry.type === "MultiPolygon" &&
|
||||
feature.geometry.coordinates?.[0]?.[0]?.[0]
|
||||
) {
|
||||
// For multipolygons, use the first polygon's first ring for centroid
|
||||
const coordinates = feature.geometry.coordinates[0][0];
|
||||
let lat = 0;
|
||||
@ -61,7 +69,10 @@ function getCoordinatesFromCountryCoder(countryCode: string): [number, number] |
|
||||
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get coordinates for country ${countryCode}:`, error);
|
||||
console.warn(
|
||||
`Failed to get coordinates for country ${countryCode}:`,
|
||||
error
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@ -90,7 +101,6 @@ export default function GeographicMap({
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
|
||||
/**
|
||||
* Get coordinates for a country code
|
||||
*/
|
||||
@ -129,22 +139,25 @@ export default function GeographicMap({
|
||||
/**
|
||||
* Process all countries data into CountryData array
|
||||
*/
|
||||
const processCountriesData = useCallback((
|
||||
countries: Record<string, number>,
|
||||
countryCoordinates: Record<string, [number, number]>
|
||||
): CountryData[] => {
|
||||
const data = Object.entries(countries || {})
|
||||
.map(([code, count]) =>
|
||||
processCountryEntry(code, count, countryCoordinates)
|
||||
)
|
||||
.filter((item): item is CountryData => item !== null);
|
||||
const processCountriesData = useCallback(
|
||||
(
|
||||
countries: Record<string, number>,
|
||||
countryCoordinates: Record<string, [number, number]>
|
||||
): CountryData[] => {
|
||||
const data = Object.entries(countries || {})
|
||||
.map(([code, count]) =>
|
||||
processCountryEntry(code, count, countryCoordinates)
|
||||
)
|
||||
.filter((item): item is CountryData => item !== null);
|
||||
|
||||
console.log(
|
||||
`Found ${data.length} countries with coordinates out of ${Object.keys(countries).length} total countries`
|
||||
);
|
||||
console.log(
|
||||
`Found ${data.length} countries with coordinates out of ${Object.keys(countries).length} total countries`
|
||||
);
|
||||
|
||||
return data;
|
||||
}, []);
|
||||
return data;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Process country data when client is ready and dependencies change
|
||||
useEffect(() => {
|
||||
|
||||
@ -71,8 +71,7 @@ export default function MessageViewer({ messages }: MessageViewerProps) {
|
||||
: "No timestamp"}
|
||||
</span>
|
||||
<span>
|
||||
Last message:{" "}
|
||||
{(() => {
|
||||
Last message: {(() => {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
return lastMessage.timestamp
|
||||
? new Date(lastMessage.timestamp).toLocaleString()
|
||||
|
||||
253
components/examples/TRPCDemo.tsx
Normal file
253
components/examples/TRPCDemo.tsx
Normal file
@ -0,0 +1,253 @@
|
||||
/**
|
||||
* tRPC Demo Component
|
||||
*
|
||||
* This component demonstrates how to use tRPC hooks for queries and mutations.
|
||||
* Can be used as a reference for migrating existing components.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { trpc } from "@/lib/trpc-client";
|
||||
|
||||
export function TRPCDemo() {
|
||||
const [sessionFilters, setSessionFilters] = useState({
|
||||
search: "",
|
||||
page: 1,
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
// Queries
|
||||
const {
|
||||
data: sessions,
|
||||
isLoading: sessionsLoading,
|
||||
error: sessionsError,
|
||||
refetch: refetchSessions,
|
||||
} = trpc.dashboard.getSessions.useQuery(sessionFilters);
|
||||
|
||||
const { data: overview, isLoading: overviewLoading } =
|
||||
trpc.dashboard.getOverview.useQuery({});
|
||||
|
||||
const { data: topQuestions, isLoading: questionsLoading } =
|
||||
trpc.dashboard.getTopQuestions.useQuery({ limit: 3 });
|
||||
|
||||
// Mutations
|
||||
const refreshSessionsMutation = trpc.dashboard.refreshSessions.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message);
|
||||
// Invalidate and refetch sessions
|
||||
refetchSessions();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to refresh sessions: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleRefreshSessions = () => {
|
||||
refreshSessionsMutation.mutate();
|
||||
};
|
||||
|
||||
const handleSearchChange = (search: string) => {
|
||||
setSessionFilters((prev) => ({ ...prev, search, page: 1 }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">tRPC Demo</h2>
|
||||
<Button
|
||||
onClick={handleRefreshSessions}
|
||||
disabled={refreshSessionsMutation.isPending}
|
||||
variant="outline"
|
||||
>
|
||||
{refreshSessionsMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Refresh Sessions
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Overview Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Sessions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{overviewLoading ? (
|
||||
<div className="flex items-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-2xl font-bold">
|
||||
{overview?.totalSessions || 0}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Avg Messages</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{overviewLoading ? (
|
||||
<div className="flex items-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-2xl font-bold">
|
||||
{Math.round(overview?.avgMessagesSent || 0)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Sentiment Distribution
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{overviewLoading ? (
|
||||
<div className="flex items-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{overview?.sentimentDistribution.map((item) => (
|
||||
<div
|
||||
key={item.sentiment}
|
||||
className="flex justify-between text-sm"
|
||||
>
|
||||
<span>{item.sentiment}</span>
|
||||
<Badge variant="outline">{item.count}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Top Questions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Questions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{questionsLoading ? (
|
||||
<div className="flex items-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Loading questions...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{topQuestions?.map((item, index) => (
|
||||
<div key={index} className="flex justify-between items-center">
|
||||
<span className="text-sm">{item.question}</span>
|
||||
<Badge>{item.count}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sessions List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
Sessions
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
placeholder="Search sessions..."
|
||||
value={sessionFilters.search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sessionsError && (
|
||||
<div className="text-red-600 mb-4">
|
||||
Error loading sessions: {sessionsError.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionsLoading ? (
|
||||
<div className="flex items-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Loading sessions...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sessions?.sessions.map((session) => (
|
||||
<div key={session.id} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium">Session {session.id}</span>
|
||||
<Badge
|
||||
variant={
|
||||
session.sentiment === "POSITIVE"
|
||||
? "default"
|
||||
: session.sentiment === "NEGATIVE"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{session.sentiment}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{session.messagesSent} messages
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{session.summary}
|
||||
</p>
|
||||
{session.questions && session.questions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{session.questions.slice(0, 3).map((question, idx) => (
|
||||
<Badge key={idx} variant="outline" className="text-xs">
|
||||
{question.length > 50
|
||||
? `${question.slice(0, 50)}...`
|
||||
: question}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Pagination Info */}
|
||||
{sessions && (
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Showing {sessions.sessions.length} of{" "}
|
||||
{sessions.pagination.totalCount} sessions (Page{" "}
|
||||
{sessions.pagination.page} of {sessions.pagination.totalPages}
|
||||
)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
components/providers/TRPCProvider.tsx
Normal file
42
components/providers/TRPCProvider.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* tRPC Provider Component
|
||||
*
|
||||
* Simplified provider for tRPC integration.
|
||||
* The tRPC client is configured in trpc-client.ts and used directly in components.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { useState } from "react";
|
||||
|
||||
interface TRPCProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TRPCProvider({ children }: TRPCProviderProps) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Disable automatic refetching for better UX
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes (was cacheTime)
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user