Files
tool-evaluator/artifacts/toolrate/src/pages/tool-detail.tsx
T
opencode 8f2fd89847
Build & Push Docker Image / build (push) Successful in 2m49s
feat(import): bulk tool import (CSV/JSON/YAML) for admins; NetBox-style help button
- Add POST /admin/tools/import with format auto-detect, CSV delimiters
  (comma/semicolon/tab), per-row validation via CreateToolBody, bulk insert,
  audit log entries per imported tool; gated by new 'tool-import' feature
  flag (premium/enterprise; admins always pass)
- Add tool-import-dialog UI (format tabs, delimiter select, textarea, file
  upload, result/error list) behind hasFeature('tool-import')
- Replace FieldHelp question marks and bare GuideHelp links with a NetBox-style
  'Hilfe/Help' outline button (HelpCircle + text) in form headers only
- Sync locales to 482 keys per language (de/en), update handbook docs
  (administration import section, index/plaene feature tables), regenerate
  API client + zod schemas, add yaml dependency
2026-08-04 23:09:11 +02:00

955 lines
43 KiB
TypeScript

import { useState, useEffect } from "react";
import { useRoute, useLocation } from "wouter";
import {
useGetTool,
getGetToolQueryKey,
useListToolRatings,
getListToolRatingsQueryKey,
useGetRatingDistribution,
getGetRatingDistributionQueryKey,
useGetToolRatingHistory,
getGetToolRatingHistoryQueryKey,
useCreateRating,
getGetTopToolsQueryKey,
getGetAnalyticsSummaryQueryKey,
getListCategoriesQueryKey,
getListAllFeaturesQueryKey
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { format } from "date-fns";
import { Layout } from "@/components/layout";
import { RatingStars } from "@/components/rating-stars";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, Legend } from "recharts";
import { ExternalLink, Star, ArrowLeft, Plus, Pencil, Trash2, Link as LinkIcon, DollarSign, Euro, Bookmark } from "lucide-react";
import { Link } from "wouter";
import { useAuth } from "@/hooks/use-auth";
import { useWatchlist } from "@/hooks/use-watchlist";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { useDeleteTool, getListToolsQueryKey } from "@workspace/api-client-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { customFetch } from "@workspace/api-client-react";
import { recordRecentTool } from "@/lib/recent-tools";
import { GuideHelp } from "@/components/guide-help";
const ratingSchema = z.object({
usefulness: z.number().min(1).max(5),
usability: z.number().min(1).max(5),
comment: z.string().optional(),
reviewerName: z.string().optional(),
});
type RatingFormValues = z.infer<typeof ratingSchema>;
export default function ToolDetail() {
const [match, params] = useRoute("/tools/:id");
const [, setLocation] = useLocation();
const id = parseInt(params?.id || "0", 10);
const queryClient = useQueryClient();
const { toast } = useToast();
const [isReviewFormOpen, setIsReviewFormOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const { user, isAdmin, hasFeature } = useAuth();
const { t } = useTranslation();
const { isWatched, toggle: toggleWatchlist, canWatchlist } = useWatchlist();
const canManageCosts = hasFeature("costs");
const hasTrash = hasFeature("trash");
const deleteTool = useDeleteTool();
const [similarData, setSimilarData] = useState<{ manual: any[]; auto: any[] } | null>(null);
const [similarLoading, setSimilarLoading] = useState(false);
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [linkToolId, setLinkToolId] = useState("");
const [linkType, setLinkType] = useState("similar");
const [linkNotes, setLinkNotes] = useState("");
const [costs, setCosts] = useState<any[]>([]);
const [costsLoading, setCostsLoading] = useState(false);
const [costDialogOpen, setCostDialogOpen] = useState(false);
const [editCost, setEditCost] = useState<any | null>(null);
const [costLicenseType, setCostLicenseType] = useState("subscription");
const [costBillingPeriod, setCostBillingPeriod] = useState("monthly");
const [costAmount, setCostAmount] = useState("");
const [costCurrency, setCostCurrency] = useState("EUR");
const [costNotes, setCostNotes] = useState("");
useEffect(() => {
if (!id) return;
setSimilarLoading(true);
customFetch<any>(`/api/tools/${id}/similar`)
.then((data) => setSimilarData(data))
.catch(() => {})
.finally(() => setSimilarLoading(false));
}, [id]);
async function handleCreateRelation() {
if (!linkToolId) return;
try {
await customFetch(`/api/tools/${id}/relations`, {
method: "POST",
body: JSON.stringify({ relatedToolId: Number(linkToolId), relationType: linkType, notes: linkNotes || undefined }),
});
toast({ title: t("detail.toastRelationCreated") });
setLinkDialogOpen(false);
setLinkToolId("");
setLinkNotes("");
setLinkType("similar");
setSimilarData(await customFetch<any>(`/api/tools/${id}/similar`));
} catch (err: any) {
toast({ title: t("detail.toastRelationFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
}
}
async function handleDeleteRelation(relationId: number) {
try {
await customFetch(`/api/tools/relations/${relationId}`, { method: "DELETE" });
toast({ title: t("detail.toastRelationDeleted") });
setSimilarData(await customFetch<any>(`/api/tools/${id}/similar`));
} catch {
toast({ title: t("detail.toastRelationDeleteFailed"), variant: "destructive" });
}
}
useEffect(() => {
if (!id) return;
setCostsLoading(true);
customFetch<any[]>(`/api/tools/${id}/costs`)
.then((data) => setCosts(data ?? []))
.catch(() => {})
.finally(() => setCostsLoading(false));
}, [id]);
async function handleSaveCost() {
const url = editCost ? `/api/costs/${editCost.id}` : `/api/tools/${id}/costs`;
const method = editCost ? "PATCH" : "POST";
const body: Record<string, unknown> = {
licenseType: costLicenseType,
billingPeriod: costLicenseType === "subscription" ? costBillingPeriod : null,
currency: costCurrency,
notes: costNotes || null,
};
if (costAmount) body.cost = costAmount;
try {
await customFetch(url, { method, body: JSON.stringify(body) });
toast({ title: editCost ? t("detail.toastCostUpdated") : t("detail.toastCostAdded") });
setCostDialogOpen(false);
resetCostForm();
setCosts(await customFetch<any[]>(`/api/tools/${id}/costs`));
} catch (err: any) {
toast({ title: t("detail.toastCostFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
}
}
function resetCostForm() {
setEditCost(null);
setCostLicenseType("subscription");
setCostBillingPeriod("monthly");
setCostAmount("");
setCostCurrency("EUR");
setCostNotes("");
}
function openEditCost(c: any) {
setEditCost(c);
setCostLicenseType(c.licenseType);
setCostBillingPeriod(c.billingPeriod ?? "monthly");
setCostAmount(c.cost ?? "");
setCostCurrency(c.currency ?? "EUR");
setCostNotes(c.notes ?? "");
setCostDialogOpen(true);
}
async function handleDeleteCost(costId: number) {
try {
await customFetch(`/api/costs/${costId}`, { method: "DELETE" });
toast({ title: t("detail.toastCostDeleted") });
setCosts(await customFetch<any[]>(`/api/tools/${id}/costs`));
} catch {
toast({ title: t("detail.toastCostDeleteFailed"), variant: "destructive" });
}
}
const { data: tool, isLoading: loadingTool } = useGetTool(id, {
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) }
});
useEffect(() => {
if (tool?.id && tool.name) recordRecentTool(tool.id, tool.name);
}, [tool]);
const { data: ratings, isLoading: loadingRatings } = useListToolRatings(id, {
query: { enabled: !!id, queryKey: getListToolRatingsQueryKey(id) }
});
const { data: distribution, isLoading: loadingDistribution } = useGetRatingDistribution({ toolId: id }, {
query: { enabled: !!id, queryKey: getGetRatingDistributionQueryKey({ toolId: id }) }
});
const { data: ratingHistory, isLoading: loadingRatingHistory } = useGetToolRatingHistory(id, {
query: { enabled: !!id, queryKey: getGetToolRatingHistoryQueryKey(id) }
});
const createRating = useCreateRating();
const form = useForm<RatingFormValues>({
resolver: zodResolver(ratingSchema),
defaultValues: {
usefulness: 0,
usability: 0,
comment: "",
reviewerName: "",
},
});
const onSubmit = (data: RatingFormValues) => {
createRating.mutate({ id, data }, {
onSuccess: () => {
toast({
title: t("detail.toastRatingSubmitted"),
description: t("detail.toastRatingThanks"),
});
setIsReviewFormOpen(false);
form.reset();
// Invalidate queries
queryClient.invalidateQueries({ queryKey: getGetToolQueryKey(id) });
queryClient.invalidateQueries({ queryKey: getListToolRatingsQueryKey(id) });
queryClient.invalidateQueries({ queryKey: getGetRatingDistributionQueryKey({ toolId: id }) });
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
},
onError: (error) => {
toast({
title: t("detail.toastRatingFailed"),
description: error.data?.error || error.message || t("detail.unexpectedError"),
variant: "destructive"
});
}
});
};
if (!match || isNaN(id)) {
return (
<Layout>
<div className="flex flex-col items-center justify-center py-20">
<h2 className="text-2xl font-bold">{t("detail.invalidToolId")}</h2>
<Button variant="link" asChild className="mt-4">
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> {t("detail.backToBrowse")}</Link>
</Button>
</div>
</Layout>
);
}
const usefulnessData = distribution?.usefulness.map(b => ({ score: b.score, count: b.count })).reverse() || [];
const usabilityData = distribution?.usability.map(b => ({ score: b.score, count: b.count })).reverse() || [];
function canEdit(toolData: { createdBy?: string | null }): boolean {
if (!user) return false;
if (isAdmin) return true;
return toolData.createdBy === user.sub ||
toolData.createdBy === user.preferredUsername;
}
function handleDelete() {
deleteTool.mutate(
{ id },
{
onSuccess: () => {
toast({ title: t("detail.toastToolDeleted") });
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
setLocation("/tools");
},
onError: (err) => {
toast({ title: t("detail.toastDeleteFailed"), description: err.data?.error || err.message, variant: "destructive" });
setDeleteOpen(false);
},
},
);
}
return (
<>
<Layout>
<div className="space-y-6 max-w-5xl mx-auto pb-10">
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> {t("detail.backToBrowse")}</Link>
</Button>
{/* Header Section */}
{loadingTool ? (
<div className="space-y-4">
<Skeleton className="h-10 w-1/3" />
<Skeleton className="h-6 w-1/4" />
<Skeleton className="h-24 w-full" />
</div>
) : tool ? (
<div className="bg-card border rounded-xl p-6 md:p-8 space-y-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/5 rounded-bl-full -z-10" />
<div className="flex flex-col md:flex-row justify-between gap-6 items-start">
<div className="space-y-4 flex-1">
<div className="flex items-center gap-4 flex-wrap">
<div className="w-12 h-12 rounded-lg border bg-muted flex items-center justify-center overflow-hidden shrink-0">
{tool.iconUrl ? (
<img
src={tool.iconUrl}
alt={tool.name}
className="w-full h-full object-contain p-1"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<span className="text-xl font-bold text-muted-foreground select-none">
{tool.name.charAt(0).toUpperCase()}
</span>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-3xl font-bold">{tool.name}</h1>
<Badge variant="outline" className="text-sm bg-background">{tool.category}</Badge>
</div>
</div>
<p className="text-lg text-muted-foreground max-w-3xl">
{tool.description}
</p>
<div className="flex flex-wrap gap-2 pt-2">
{tool.tags?.map(tag => (
<Badge key={tag} variant="secondary">{tag}</Badge>
))}
</div>
</div>
<div className="flex flex-col gap-3 shrink-0 md:items-end w-full md:w-auto bg-muted/30 p-4 rounded-lg">
<div className="flex items-center gap-2">
<Star className="w-6 h-6 fill-primary text-primary" />
<span className="text-3xl font-bold">{tool.avgCombined ? tool.avgCombined.toFixed(1) : "N/A"}</span>
<span className="text-muted-foreground self-end mb-1">/ 5</span>
</div>
<div className="text-sm text-muted-foreground">
{t("detail.basedOnReview", { count: tool.ratingCount })}
</div>
{canWatchlist && (
<Button
variant={isWatched(id) ? "default" : "outline"}
className="w-full gap-2"
onClick={() => toggleWatchlist(Number(id))}
>
<Bookmark className={cn("w-4 h-4", isWatched(id) && "fill-current")} />
{isWatched(id) ? t("detail.savedToWatchlist") : t("detail.saveToWatchlist")}
</Button>
)}
{tool.websiteUrl && (
<Button asChild className="w-full mt-2" variant="outline">
<a href={tool.websiteUrl} target="_blank" rel="noopener noreferrer">
{t("detail.visitWebsite")} <ExternalLink className="w-4 h-4 ml-2" />
</a>
</Button>
)}
{canEdit(tool) && (
<div className="flex gap-2 w-full mt-1">
<Button asChild variant="outline" size="sm" className="flex-1 gap-2">
<Link href={`/tools/${id}/edit`}>
<Pencil className="w-3.5 h-3.5" /> {t("detail.edit")}
</Link>
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 gap-2 text-destructive hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="w-3.5 h-3.5" /> {t("detail.delete")}
</Button>
</div>
)}
</div>
</div>
{tool.features && tool.features.length > 0 && (
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold mb-3">{t("detail.keyFeatures")}</h3>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{tool.features.map((feature, i) => (
<li key={i} className="flex items-start gap-2">
<div className="mt-1 bg-primary/20 p-0.5 rounded text-primary">
<Plus className="w-3 h-3" />
</div>
<span>{feature}</span>
</li>
))}
</ul>
</div>
)}
</div>
) : (
<div className="text-center py-10">{t("detail.toolNotFound")}</div>
)}
{/* Similar Tools Section */}
{tool && (
<div className="space-y-4 mt-8">
<div className="flex items-center justify-between">
<h3 className="text-xl font-bold">{t("detail.similarTools")}</h3>
{isAdmin && (
<Button variant="outline" size="sm" onClick={() => setLinkDialogOpen(true)} className="gap-2">
<LinkIcon className="w-4 h-4" /> {t("detail.linkTool")}
</Button>
)}
</div>
{similarLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-20 rounded-lg" />)}
</div>
) : similarData && (similarData.manual.length > 0 || similarData.auto.length > 0) ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{similarData.manual.map((item: any) => (
<Link key={item.relationId} href={`/tools/${item.id}`} className="group">
<Card className="hover-elevate transition-all hover:border-primary/50 cursor-pointer h-full">
<CardContent className="p-4 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{item.name}</span>
<Badge variant={item.relationType === "replaces" ? "destructive" : item.relationType === "superseded_by" ? "default" : "secondary"} className="text-[10px] px-1 py-0 shrink-0">
{item.relationType === "superseded_by" ? "replaces" : item.relationType}
</Badge>
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<span>{item.category}</span>
{item.avgCombined != null && (
<>
<span>·</span>
<span className="flex items-center gap-0.5">
<Star className="w-3 h-3 fill-primary text-primary" />
{item.avgCombined.toFixed(1)}
</span>
</>
)}
</div>
{item.notes && <p className="text-xs text-muted-foreground mt-1 italic">{item.notes}</p>}
</div>
{isAdmin && (
<Button
variant="ghost"
size="icon"
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity h-7 w-7"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleDeleteRelation(item.relationId); }}
>
<Trash2 className="w-3.5 h-3.5 text-destructive" />
</Button>
)}
</CardContent>
</Card>
</Link>
))}
{similarData.auto.map((item: any) => (
<Link key={item.id} href={`/tools/${item.id}`} className="group">
<Card className="hover-elevate transition-all hover:border-primary/50 cursor-pointer h-full">
<CardContent className="p-4 flex items-center gap-3">
<div className="min-w-0 flex-1">
<span className="font-medium truncate block">{item.name}</span>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<span>{item.category}</span>
{item.avgCombined != null && (
<>
<span>·</span>
<span className="flex items-center gap-0.5">
<Star className="w-3 h-3 fill-primary text-primary" />
{item.avgCombined.toFixed(1)}
</span>
</>
)}
<span>·</span>
<span>{t("detail.score")}: {item.score}</span>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
) : similarData && similarData.manual.length === 0 && similarData.auto.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">{t("detail.noSimilarTools")}</p>
) : null}
</div>
)}
{/* Link Tool Dialog */}
<Dialog open={linkDialogOpen} onOpenChange={setLinkDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("detail.linkSimilarTool")}</DialogTitle>
<DialogDescription>{t("detail.linkSimilarToolSub")}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium">{t("detail.toolId")}</label>
<Input
type="number"
placeholder={t("detail.toolIdPlaceholder")}
value={linkToolId}
onChange={(e) => setLinkToolId(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t("detail.relationType")}</label>
<Select value={linkType} onValueChange={setLinkType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="similar">{t("detail.relationSimilar")}</SelectItem>
<SelectItem value="replaces">{t("detail.relationReplaces")}</SelectItem>
<SelectItem value="superseded_by">{t("detail.relationSupersededBy")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t("detail.notesOptional")}</label>
<Textarea
placeholder={t("detail.notesPlaceholder")}
value={linkNotes}
onChange={(e) => setLinkNotes(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setLinkDialogOpen(false)}>{t("common.cancel")}</Button>
<Button onClick={handleCreateRelation} disabled={!linkToolId}>{t("detail.createLink")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Costs Section */}
{tool && (
<div className="space-y-4 mt-8">
<div className="flex items-center justify-between">
<h3 className="text-xl font-bold">{t("detail.costs")}</h3>
{canManageCosts && (
<Button variant="outline" size="sm" onClick={() => { resetCostForm(); setCostDialogOpen(true); }} className="gap-2">
<Plus className="w-4 h-4" /> {t("detail.addCost")}
</Button>
)}
</div>
{costsLoading ? (
<Skeleton className="h-16 rounded-lg" />
) : costs.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{costs.map((c, i) => (
<Card key={c.id ?? i} className="relative group">
<CardContent className="p-4">
<div className="flex justify-between items-start">
<div>
<div className="flex items-center gap-1.5 mb-1">
<Badge variant="outline" className="text-xs">{c.licenseType}</Badge>
{c.billingPeriod && <span className="text-[10px] text-muted-foreground uppercase">{c.billingPeriod}</span>}
</div>
<div className="text-lg font-bold">
{c.cost != null ? `${c.cost} ${c.currency ?? ""}` : t("detail.licenseFree")}
</div>
{c.notes && <p className="text-xs text-muted-foreground mt-1 italic">{c.notes}</p>}
</div>
{canManageCosts && (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEditCost(c)}>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => handleDeleteCost(c.id)}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
) : (
<p className="text-sm text-muted-foreground py-4">{t("detail.noCostInfo")}</p>
)}
</div>
)}
{/* Cost Dialog */}
<Dialog open={costDialogOpen} onOpenChange={(o) => { if (!o) setCostDialogOpen(false); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")}
</DialogTitle>
<DialogDescription>{t("detail.costDialogSub")}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium">{t("detail.licenseType")}</label>
<Select value={costLicenseType} onValueChange={setCostLicenseType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="free">{t("detail.licenseFree")}</SelectItem>
<SelectItem value="subscription">{t("detail.licenseSubscription")}</SelectItem>
<SelectItem value="one_time">{t("detail.licenseOneTime")}</SelectItem>
<SelectItem value="usage_based">{t("detail.licenseUsageBased")}</SelectItem>
</SelectContent>
</Select>
</div>
{costLicenseType === "subscription" && (
<div className="space-y-2">
<label className="text-sm font-medium">{t("detail.billingPeriod")}</label>
<Select value={costBillingPeriod} onValueChange={setCostBillingPeriod}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="monthly">{t("detail.billingMonthly")}</SelectItem>
<SelectItem value="quarterly">{t("detail.billingQuarterly")}</SelectItem>
<SelectItem value="yearly">{t("detail.billingYearly")}</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t("detail.costLabel")}</label>
<Input type="number" step="0.01" placeholder="0.00" value={costAmount} onChange={(e) => setCostAmount(e.target.value)} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t("detail.currency")}</label>
<Select value={costCurrency} onValueChange={setCostCurrency}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="EUR">EUR</SelectItem>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="GBP">GBP</SelectItem>
<SelectItem value="CHF">CHF</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t("detail.costNotes")}</label>
<Textarea placeholder={t("detail.costNotesPlaceholder")} value={costNotes} onChange={(e) => setCostNotes(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCostDialogOpen(false)}>{t("common.cancel")}</Button>
<Button onClick={handleSaveCost}>{t("common.save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Ratings & Reviews Section */}
{tool && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column: Stats */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t("detail.ratingBreakdown")}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<div className="flex justify-between items-center mb-2">
<span className="font-medium">{t("detail.usefulness")}</span>
<span className="font-bold">{tool.avgUsefulness ? tool.avgUsefulness.toFixed(1) : "0.0"}</span>
</div>
<Progress value={((tool.avgUsefulness || 0) / 5) * 100} className="h-2" />
</div>
<div>
<div className="flex justify-between items-center mb-2">
<span className="font-medium">{t("detail.usability")}</span>
<span className="font-bold">{tool.avgUsability ? tool.avgUsability.toFixed(1) : "0.0"}</span>
</div>
<Progress value={((tool.avgUsability || 0) / 5) * 100} className="h-2" />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("detail.scoreDistribution")}</CardTitle>
</CardHeader>
<CardContent>
{loadingDistribution ? (
<Skeleton className="h-48 w-full" />
) : (
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={usefulnessData} layout="vertical" margin={{ top: 0, right: 0, bottom: 0, left: -20 }}>
<XAxis type="number" hide />
<YAxis dataKey="score" type="category" axisLine={false} tickLine={false} tickFormatter={(val) => `${val}`} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />
<Tooltip cursor={{ fill: 'transparent' }} />
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[0, 4, 4, 0]} barSize={12} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("detail.scoreTrend")}</CardTitle>
</CardHeader>
<CardContent>
{loadingRatingHistory ? (
<Skeleton className="h-48 w-full" />
) : ratingHistory && ratingHistory.length > 1 ? (
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={ratingHistory} margin={{ top: 5, right: 10, bottom: 0, left: -20 }}>
<CartesianGrid strokeDasharray="3 3" opacity={0.2} />
<XAxis dataKey="date" hide />
<YAxis domain={[0, 5]} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} axisLine={false} tickLine={false} />
<Tooltip labelFormatter={(_, payload) => (payload?.[0] ? format(new Date(payload[0].payload.date), "dd.MM.yyyy HH:mm") : "")} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line type="monotone" dataKey="combined" name={t("detail.score")} stroke="hsl(var(--primary))" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="usefulness" name={t("detail.usefulness")} stroke="hsl(var(--success))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
<Line type="monotone" dataKey="usability" name={t("detail.usability")} stroke="hsl(var(--warning))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
</LineChart>
</ResponsiveContainer>
</div>
) : (
<p className="text-sm text-muted-foreground">{t("detail.notEnoughRatings")}</p>
)}
</CardContent>
</Card>
</div>
{/* Right Column: Reviews */}
<div className="lg:col-span-2 space-y-6">
<div className="flex justify-between items-center">
<h3 className="text-2xl font-bold">{t("detail.reviews")}</h3>
{!isReviewFormOpen && user && (
<Button onClick={() => setIsReviewFormOpen(true)}>{t("detail.addReview")}</Button>
)}
</div>
{isReviewFormOpen && (
<Card className="border-primary shadow-sm">
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1.5">
<CardTitle>{t("detail.addReview")}</CardTitle>
<CardDescription>{t("detail.shareExperience", { name: tool.name })}</CardDescription>
</div>
<GuideHelp guide="bewerten" label={t("detail.addReview")} />
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<FormField
control={form.control}
name="usefulness"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("detail.usefulness")}
</FormLabel>
<div className="py-2">
<RatingStars
value={field.value}
interactive
onChange={field.onChange}
size="lg"
/>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="usability"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("detail.usability")}
</FormLabel>
<div className="py-2">
<RatingStars
value={field.value}
interactive
onChange={field.onChange}
size="lg"
/>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="comment"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("detail.commentOptional")}
</FormLabel>
<FormControl>
<Textarea
placeholder={t("detail.commentPlaceholder")}
className="resize-none min-h-[100px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="reviewerName"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("detail.nameOptional")}
</FormLabel>
<FormControl>
<Input placeholder={t("detail.anonymousPlaceholder")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => setIsReviewFormOpen(false)}
disabled={createRating.isPending}
>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={createRating.isPending}>
{createRating.isPending ? t("common.loading") : t("detail.submit")}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
)}
<div className="space-y-4">
{loadingRatings ? (
Array.from({ length: 3 }).map((_, i) => (
<Card key={i}><CardContent className="p-6"><Skeleton className="h-24 w-full" /></CardContent></Card>
))
) : ratings && ratings.length > 0 ? (
ratings.map((rating) => (
<Card key={rating.id} className="bg-card">
<CardContent className="p-6">
<div className="flex justify-between items-start mb-4">
<div>
<span className="font-semibold">{rating.reviewerName || t("detail.anonymousEngineer")}</span>
<span className="text-muted-foreground text-sm ml-2">
{format(new Date(rating.createdAt), "MMM d, yyyy")}
</span>
</div>
</div>
<div className="flex gap-6 mb-4">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">{t("detail.usefulness")}</span>
<RatingStars value={rating.usefulness} size="sm" />
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">{t("detail.usability")}</span>
<RatingStars value={rating.usability} size="sm" />
</div>
</div>
{rating.comment && (
<p className="text-foreground leading-relaxed">
{rating.comment}
</p>
)}
</CardContent>
</Card>
))
) : (
<div className="text-center py-12 bg-muted/30 border border-dashed rounded-xl">
<Star className="w-12 h-12 text-muted-foreground/30 mx-auto mb-3" />
<h4 className="text-lg font-medium">{t("detail.noReviews")}</h4>
<p className="text-muted-foreground mt-1">{t("detail.beFirstToReview")}</p>
</div>
)}
</div>
</div>
</div>
)}
</div>
</Layout>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("detail.deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{hasTrash ? (
<>{t("detail.deleteToTrash", { name: tool?.name })}</>
) : (
<>{t("detail.deletePermanent", { name: tool?.name })}</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
disabled={deleteTool.isPending}
>
{deleteTool.isPending ? t("detail.deleting") : t("detail.deleteAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}