8f2fd89847
Build & Push Docker Image / build (push) Successful in 2m49s
- 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
955 lines
43 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|