feat: wire analytics to real data and add audit logging across all endpoints

Replace mock chart data on reports page with real backend queries (jobs over
time, locale stats, usage stats, quality metrics). Add audit logging to auth
(login/login_failed), file management (upload/delete TM and reference files),
and feedback submission so the system logs page shows complete activity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-10 17:17:14 -04:00
parent b11b2df0e2
commit 5ef7e588b6
9 changed files with 513 additions and 169 deletions

View file

@ -11,10 +11,12 @@ from app.schemas.files import (
ReferenceFileResponse,
TMFileResponse,
)
from app.services.audit_service import AuditService
from app.services.file_service import FileService
router = APIRouter(prefix="/files", tags=["files"])
file_service = FileService()
audit_service = AuditService()
# ---- TM Files ----
@ -39,6 +41,12 @@ async def upload_tm_file(
db, client_id, locale_code, channel, file.file, file.filename,
uploaded_by=current_user["user_id"],
)
await audit_service.log(
db, action="upload_tm", entity_type="tm_file", entity_id=str(tm.id),
user_id=current_user["user_id"],
details={"filename": tm.filename, "locale": locale_code, "channel": channel, "segments": tm.segment_count},
)
await db.commit()
return FileUploadResponse(
id=tm.id,
filename=tm.filename,
@ -94,6 +102,11 @@ async def delete_tm_file(
deleted = await file_service.delete_tm_file(db, file_id)
if not deleted:
raise HTTPException(status_code=404, detail="TM file not found")
await audit_service.log(
db, action="delete_tm", entity_type="tm_file", entity_id=str(file_id),
user_id=current_user["user_id"],
)
await db.commit()
# ---- Reference Files ----
@ -120,6 +133,12 @@ async def upload_reference_file(
db, client_id, file_type, locale_scope, file.file, file.filename,
uploaded_by=current_user["user_id"],
)
await audit_service.log(
db, action="upload_reference", entity_type="reference_file", entity_id=str(ref.id),
user_id=current_user["user_id"],
details={"filename": ref.filename, "file_type": file_type.value, "locale_scope": locale_scope},
)
await db.commit()
return FileUploadResponse(
id=ref.id,
filename=ref.filename,
@ -177,3 +196,8 @@ async def delete_reference_file(
deleted = await file_service.delete_reference_file(db, file_id)
if not deleted:
raise HTTPException(status_code=404, detail="Reference file not found")
await audit_service.log(
db, action="delete_reference", entity_type="reference_file", entity_id=str(file_id),
user_id=current_user["user_id"],
)
await db.commit()

View file

@ -7,12 +7,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.schemas.feedback import FeedbackCreate, FeedbackResponse
from app.schemas.output import OutputPreviewResponse
from app.services.audit_service import AuditService
from app.services.feedback_service import FeedbackService
from app.services.output_service import OutputService
router = APIRouter(prefix="/output", tags=["output"])
output_service = OutputService()
feedback_service = FeedbackService()
audit_service = AuditService()
@router.get(
@ -49,6 +51,12 @@ async def create_feedback(
feedback = await feedback_service.create_feedback(
db, body, current_user["user_id"]
)
await audit_service.log(
db, action="feedback", entity_type="output_row", entity_id=str(body.output_row_id),
user_id=current_user["user_id"],
details={"flag_type": body.flag_type.value, "comment": body.comment},
)
await db.commit()
return FeedbackResponse.model_validate(feedback)

View file

@ -48,3 +48,31 @@ async def get_quality_metrics(
) -> dict[str, Any]:
"""Get quality metrics (confidence tier distribution, feedback stats)."""
return await report_service.get_quality_metrics(db, client_id=client_id)
@router.get("/locale-stats")
async def get_locale_stats(
client_id: UUID | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> list[dict[str, Any]]:
"""Get per-locale stats (tokens, cost, avg duration)."""
return await report_service.get_locale_stats(
db, client_id=client_id, date_from=date_from, date_to=date_to
)
@router.get("/jobs-over-time")
async def get_jobs_over_time(
client_id: UUID | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> list[dict[str, Any]]:
"""Get job counts grouped by week for chart display."""
return await report_service.get_jobs_over_time(
db, client_id=client_id, date_from=date_from, date_to=date_to
)

View file

@ -1,26 +1,45 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.schemas import LoginRequest, RefreshRequest, TokenResponse, UserClaims
from app.auth.service import AuthService
from app.dependencies import get_current_user, get_db
from app.services.audit_service import AuditService
router = APIRouter(prefix="/auth", tags=["auth"])
auth_service = AuthService()
audit_service = AuditService()
@router.post("/login", response_model=TokenResponse)
async def login(
body: LoginRequest,
request: Request,
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
"""Authenticate user and return access + refresh tokens."""
result = await auth_service.login(body.email, body.password, db)
if result is None:
await audit_service.log(
db, action="login_failed", entity_type="user", entity_id=body.email,
details={"reason": "Invalid credentials"},
ip_address=request.client.host if request.client else None,
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
# Extract user_id from the access token claims
claims = auth_service.validate_token(result["access_token"])
user_id = claims["sub"] if claims else body.email
await audit_service.log(
db, action="login", entity_type="user", entity_id=str(user_id),
user_id=user_id if claims else None,
details={"email": body.email},
ip_address=request.client.host if request.client else None,
)
await db.commit()
return TokenResponse(**result)

View file

@ -2,7 +2,7 @@ from datetime import datetime
from typing import Any
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit import TokenUsageLog
@ -131,3 +131,85 @@ class ReportService:
"confidence_tiers": tier_breakdown,
"feedback_distribution": feedback_breakdown,
}
async def get_locale_stats(
self,
db: AsyncSession,
client_id: UUID | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> list[dict[str, Any]]:
"""Get per-locale aggregate stats (tokens, cost, avg duration, job count)."""
query = select(
LocaleInstance.locale_code,
func.count(LocaleInstance.id).label("count"),
func.sum(LocaleInstance.token_usage).label("total_tokens"),
func.sum(LocaleInstance.estimated_cost).label("total_cost"),
func.avg(
func.extract("epoch", LocaleInstance.completed_at)
- func.extract("epoch", LocaleInstance.started_at)
).label("avg_duration_seconds"),
).where(
LocaleInstance.status == LocaleStatus.complete,
LocaleInstance.started_at.isnot(None),
LocaleInstance.completed_at.isnot(None),
).group_by(LocaleInstance.locale_code)
if client_id:
query = query.join(Job).where(Job.client_id == client_id)
if date_from:
query = query.join(Job, isouter=True).where(Job.created_at >= date_from)
if date_to:
if "job" not in str(query):
query = query.join(Job, isouter=True)
query = query.where(Job.created_at <= date_to)
result = await db.execute(query)
return [
{
"locale": row.locale_code,
"count": row.count,
"total_tokens": row.total_tokens or 0,
"total_cost": float(row.total_cost or 0.0),
"avg_duration_minutes": round((row.avg_duration_seconds or 0) / 60, 1),
}
for row in result.all()
]
async def get_jobs_over_time(
self,
db: AsyncSession,
client_id: UUID | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> list[dict[str, Any]]:
"""Get job counts grouped by week."""
query = select(
func.date_trunc("week", Job.created_at).label("week"),
func.count(Job.id).label("total"),
func.sum(case((Job.status == JobStatus.complete, 1), else_=0)).label("completed_raw"),
func.sum(case((Job.status == JobStatus.error, 1), else_=0)).label("errors_raw"),
).group_by(
func.date_trunc("week", Job.created_at)
).order_by(
func.date_trunc("week", Job.created_at)
)
if client_id:
query = query.where(Job.client_id == client_id)
if date_from:
query = query.where(Job.created_at >= date_from)
if date_to:
query = query.where(Job.created_at <= date_to)
result = await db.execute(query)
rows = []
for row in result.all():
week_str = row.week.strftime("%b %d") if row.week else "?"
rows.append({
"name": week_str,
"jobs": row.total or 0,
"completed": row.completed_raw or 0,
"errors": row.errors_raw or 0,
})
return rows

View file

@ -68,7 +68,12 @@ const ACTION_LABELS: Record<string, string> = {
delete: "JOB_DELETED",
rerun_locale: "LOCALE_RERUN",
login: "AUTH_LOGIN",
login_failed: "LOGIN_FAILED",
feedback: "FEEDBACK",
upload_tm: "TM_UPLOADED",
delete_tm: "TM_DELETED",
upload_reference: "REF_UPLOADED",
delete_reference: "REF_DELETED",
};
export default function LogsPage() {

View file

@ -1,9 +1,8 @@
"use client";
import React from "react";
import React, { useState, useEffect, useCallback } from "react";
import { AppShell } from "@/components/layout/AppShell";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ReportChart } from "@/components/admin/ReportChart";
import {
Select,
@ -12,46 +11,117 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TrendingUp, Clock, Zap, Target } from "lucide-react";
const jobsByWeek = [
{ name: "W10", jobs: 12, completed: 10, errors: 2 },
{ name: "W11", jobs: 18, completed: 16, errors: 1 },
{ name: "W12", jobs: 15, completed: 14, errors: 1 },
{ name: "W13", jobs: 22, completed: 20, errors: 2 },
{ name: "W14", jobs: 28, completed: 25, errors: 3 },
{ name: "W15", jobs: 24, completed: 22, errors: 1 },
];
const confidenceDistribution = [
{ name: "HIGH", value: 68, fill: "#067D62" },
{ name: "MODERATE", value: 24, fill: "#F0AD4E" },
{ name: "LOW", value: 8, fill: "#CC0C39" },
];
const tokensByLocale = [
{ locale: "de-DE", input: 245000, output: 198000 },
{ locale: "fr-FR", input: 238000, output: 195000 },
{ locale: "it-IT", input: 221000, output: 182000 },
{ locale: "es-ES", input: 235000, output: 190000 },
{ locale: "nl-NL", input: 156000, output: 128000 },
{ locale: "sv-SE", input: 142000, output: 118000 },
{ locale: "pl-PL", input: 134000, output: 112000 },
{ locale: "pt-PT", input: 128000, output: 105000 },
];
const avgDuration = [
{ locale: "de-DE", duration: 4.2 },
{ locale: "fr-FR", duration: 4.5 },
{ locale: "it-IT", duration: 4.8 },
{ locale: "es-ES", duration: 4.1 },
{ locale: "nl-NL", duration: 3.8 },
{ locale: "sv-SE", duration: 3.6 },
{ locale: "pl-PL", duration: 4.9 },
{ locale: "pt-PT", duration: 3.7 },
];
import { TrendingUp, Clock, Zap, Target, Loader2 } from "lucide-react";
import {
getUsageStats,
getQualityMetrics,
getLocaleStats,
getJobsOverTime,
type UsageStats,
type QualityMetrics,
type LocaleStat,
type JobsOverTimeEntry,
} from "@/lib/api";
export default function ReportsPage() {
const [period, setPeriod] = useState("all");
const [loading, setLoading] = useState(true);
const [usage, setUsage] = useState<UsageStats | null>(null);
const [quality, setQuality] = useState<QualityMetrics | null>(null);
const [localeStats, setLocaleStats] = useState<LocaleStat[]>([]);
const [jobsOverTime, setJobsOverTime] = useState<JobsOverTimeEntry[]>([]);
const fetchAll = useCallback(async () => {
setLoading(true);
// Compute date_from based on period
let date_from: string | undefined;
if (period !== "all") {
const now = new Date();
const days = period === "7d" ? 7 : period === "30d" ? 30 : 90;
const from = new Date(now.getTime() - days * 86400000);
date_from = from.toISOString();
}
const params = date_from ? { date_from } : undefined;
try {
const [u, q, l, j] = await Promise.all([
getUsageStats(params),
getQualityMetrics(),
getLocaleStats(params),
getJobsOverTime(params),
]);
setUsage(u);
setQuality(q);
setLocaleStats(l);
setJobsOverTime(j);
} catch {
// Partial failure is ok, show what we have
} finally {
setLoading(false);
}
}, [period]);
useEffect(() => {
fetchAll();
}, [fetchAll]);
// Derived stats
const totalJobs = usage?.total_jobs || 0;
const statusBreakdown = usage?.status_breakdown || {};
const completedJobs =
(statusBreakdown["complete"] || 0) + (statusBreakdown["exported"] || 0);
const errorJobs = statusBreakdown["error"] || 0;
const successRate =
totalJobs > 0
? Math.round(((totalJobs - errorJobs) / totalJobs) * 100)
: 0;
const totalTokens = usage?.total_tokens || 0;
const avgDurationAll =
localeStats.length > 0
? (
localeStats.reduce((s, l) => s + l.avg_duration_minutes, 0) /
localeStats.length
).toFixed(1)
: "0";
// Confidence chart data
const TIER_COLORS: Record<string, string> = {
high: "#067D62",
moderate: "#F0AD4E",
low: "#CC0C39",
};
const confidenceData = Object.entries(quality?.confidence_tiers || {}).map(
([tier, count]) => ({
name: tier.toUpperCase(),
value: count,
fill: TIER_COLORS[tier] || "#999",
})
);
// Locale token chart
const tokensByLocale = localeStats.map((l) => ({
locale: l.locale,
tokens: l.total_tokens,
cost: l.total_cost,
}));
// Locale duration chart
const durationByLocale = localeStats.map((l) => ({
locale: l.locale,
duration: l.avg_duration_minutes,
}));
const periodLabel =
period === "7d"
? "7d"
: period === "30d"
? "30d"
: period === "90d"
? "90d"
: "";
return (
<AppShell>
<div className="space-y-6">
@ -59,7 +129,7 @@ export default function ReportsPage() {
<p className="text-sm text-gray-500">
Platform usage and performance analytics
</p>
<Select defaultValue="30d">
<Select value={period} onValueChange={setPeriod}>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
@ -72,130 +142,185 @@ export default function ReportsPage() {
</Select>
</div>
{/* Summary stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-amazon-teal/10 flex items-center justify-center">
<TrendingUp className="h-5 w-5 text-amazon-teal" />
</div>
<div>
<p className="text-2xl font-bold">119</p>
<p className="text-xs text-gray-500">Total Jobs (30d)</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-amazon-success/10 flex items-center justify-center">
<Target className="h-5 w-5 text-amazon-success" />
</div>
<div>
<p className="text-2xl font-bold">94%</p>
<p className="text-xs text-gray-500">Success Rate</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-amazon-orange/10 flex items-center justify-center">
<Clock className="h-5 w-5 text-amazon-orange" />
</div>
<div>
<p className="text-2xl font-bold">4.2m</p>
<p className="text-xs text-gray-500">Avg Duration/Locale</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-purple-100 flex items-center justify-center">
<Zap className="h-5 w-5 text-purple-600" />
</div>
<div>
<p className="text-2xl font-bold">2.4M</p>
<p className="text-xs text-gray-500">Total Tokens</p>
</div>
</div>
</CardContent>
</Card>
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : (
<>
{/* Summary stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-amazon-teal/10 flex items-center justify-center">
<TrendingUp className="h-5 w-5 text-amazon-teal" />
</div>
<div>
<p className="text-2xl font-bold">{totalJobs}</p>
<p className="text-xs text-gray-500">
Total Jobs{periodLabel ? ` (${periodLabel})` : ""}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-amazon-success/10 flex items-center justify-center">
<Target className="h-5 w-5 text-amazon-success" />
</div>
<div>
<p className="text-2xl font-bold">{successRate}%</p>
<p className="text-xs text-gray-500">Success Rate</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-amazon-orange/10 flex items-center justify-center">
<Clock className="h-5 w-5 text-amazon-orange" />
</div>
<div>
<p className="text-2xl font-bold">{avgDurationAll}m</p>
<p className="text-xs text-gray-500">
Avg Duration/Locale
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-purple-100 flex items-center justify-center">
<Zap className="h-5 w-5 text-purple-600" />
</div>
<div>
<p className="text-2xl font-bold">
{totalTokens > 1_000_000
? `${(totalTokens / 1_000_000).toFixed(1)}M`
: totalTokens > 1_000
? `${(totalTokens / 1_000).toFixed(1)}K`
: totalTokens}
</p>
<p className="text-xs text-gray-500">Total Tokens</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Jobs by Week</CardTitle>
</CardHeader>
<CardContent>
<ReportChart
type="bar"
data={jobsByWeek}
dataKeys={[
{ key: "completed", color: "#067D62", name: "Completed" },
{ key: "errors", color: "#CC0C39", name: "Errors" },
]}
xAxisKey="name"
/>
</CardContent>
</Card>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Jobs by Week</CardTitle>
</CardHeader>
<CardContent>
{jobsOverTime.length > 0 ? (
<ReportChart
type="bar"
data={jobsOverTime}
dataKeys={[
{
key: "completed",
color: "#067D62",
name: "Completed",
},
{ key: "errors", color: "#CC0C39", name: "Errors" },
]}
xAxisKey="name"
/>
) : (
<p className="text-sm text-gray-400 text-center py-12">
No job data for this period.
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Confidence Distribution</CardTitle>
</CardHeader>
<CardContent>
<ReportChart
type="pie"
data={confidenceDistribution}
dataKeys={[{ key: "value", color: "#FF9900", name: "Count" }]}
xAxisKey="name"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
Confidence Distribution
</CardTitle>
</CardHeader>
<CardContent>
{confidenceData.length > 0 ? (
<ReportChart
type="pie"
data={confidenceData}
dataKeys={[
{ key: "value", color: "#FF9900", name: "Count" },
]}
xAxisKey="name"
/>
) : (
<p className="text-sm text-gray-400 text-center py-12">
No output data yet.
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Token Usage by Locale</CardTitle>
</CardHeader>
<CardContent>
<ReportChart
type="bar"
data={tokensByLocale}
dataKeys={[
{ key: "input", color: "#232F3E", name: "Input" },
{ key: "output", color: "#FF9900", name: "Output" },
]}
xAxisKey="locale"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
Token Usage by Locale
</CardTitle>
</CardHeader>
<CardContent>
{tokensByLocale.length > 0 ? (
<ReportChart
type="bar"
data={tokensByLocale}
dataKeys={[
{ key: "tokens", color: "#232F3E", name: "Tokens" },
]}
xAxisKey="locale"
/>
) : (
<p className="text-sm text-gray-400 text-center py-12">
No locale data for this period.
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
Avg Duration by Locale (minutes)
</CardTitle>
</CardHeader>
<CardContent>
<ReportChart
type="bar"
data={avgDuration}
dataKeys={[
{ key: "duration", color: "#007185", name: "Duration" },
]}
xAxisKey="locale"
/>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">
Avg Duration by Locale (minutes)
</CardTitle>
</CardHeader>
<CardContent>
{durationByLocale.length > 0 ? (
<ReportChart
type="bar"
data={durationByLocale}
dataKeys={[
{
key: "duration",
color: "#007185",
name: "Duration",
},
]}
xAxisKey="locale"
/>
) : (
<p className="text-sm text-gray-400 text-center py-12">
No locale data for this period.
</p>
)}
</CardContent>
</Card>
</div>
</>
)}
</div>
</AppShell>
);

View file

@ -23,7 +23,8 @@ interface DataKey {
interface ReportChartProps {
type: "bar" | "pie";
data: Record<string, unknown>[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: Record<string, any>[];
dataKeys: DataKey[];
xAxisKey: string;
height?: number;

View file

@ -527,12 +527,64 @@ export async function deleteReferenceFile(fileId: string): Promise<void> {
// ─── Analytics ───────────────────────────────────────────────────────
export async function getAnalytics(params?: {
from?: string;
to?: string;
export interface UsageStats {
total_jobs: number;
total_tokens: number;
total_cost: number;
status_breakdown: Record<string, number>;
}
export interface QualityMetrics {
confidence_tiers: Record<string, number>;
feedback_distribution: Record<string, number>;
}
export interface LocaleStat {
locale: string;
count: number;
total_tokens: number;
total_cost: number;
avg_duration_minutes: number;
}
export interface JobsOverTimeEntry {
name: string;
jobs: number;
completed: number;
errors: number;
}
export async function getUsageStats(params?: {
date_from?: string;
date_to?: string;
client_id?: string;
}): Promise<Record<string, unknown>> {
const response = await api.get("/analytics", { params });
}): Promise<UsageStats> {
const response = await api.get("/reports/usage", { params });
return response.data;
}
export async function getQualityMetrics(params?: {
client_id?: string;
}): Promise<QualityMetrics> {
const response = await api.get("/reports/quality", { params });
return response.data;
}
export async function getLocaleStats(params?: {
date_from?: string;
date_to?: string;
client_id?: string;
}): Promise<LocaleStat[]> {
const response = await api.get("/reports/locale-stats", { params });
return response.data;
}
export async function getJobsOverTime(params?: {
date_from?: string;
date_to?: string;
client_id?: string;
}): Promise<JobsOverTimeEntry[]> {
const response = await api.get("/reports/jobs-over-time", { params });
return response.data;
}