feat: add AI Dental Shopping section with sidebar nav and Login Info page

- Add AI Dental Shopping to sidebar with Search/Tag and Login Info sub-pages
- Build full-stack Login Info CRUD: save vendor name, website, username, password per user
- Add ShoppingVendor Prisma model, run db push, regenerate client and Zod schemas
- Add storage layer, REST API at /api/shopping-vendors/, and frontend table with add/edit/delete modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gitead
2026-05-17 00:35:38 -04:00
parent edec03e893
commit e34140c2b1
217 changed files with 4081 additions and 14 deletions

View File

@@ -29,6 +29,8 @@ const ReportsPage = lazy(() => import("./pages/reports-page"));
const CloudStoragePage = lazy(() => import("./pages/cloud-storage-page"));
const JobMonitorPage = lazy(() => import("./pages/job-monitor-page"));
const ChartPage = lazy(() => import("./pages/chart-page"));
const DentalShoppingSearchTagPage = lazy(() => import("./pages/dental-shopping-search-tag-page"));
const DentalShoppingLoginInfoPage = lazy(() => import("./pages/dental-shopping-login-info-page"));
const NotFound = lazy(() => import("./pages/not-found"));
function Router() {
@@ -61,6 +63,8 @@ function Router() {
/>
<ProtectedRoute path="/reports" component={() => <ReportsPage />} />
<ProtectedRoute path="/cloud-storage" component={() => <CloudStoragePage />} />
<ProtectedRoute path="/dental-shopping/search-tag" component={() => <DentalShoppingSearchTagPage />} />
<ProtectedRoute path="/dental-shopping/login-info" component={() => <DentalShoppingLoginInfoPage />} />
<ProtectedRoute
path="/job-monitor"
component={() => <JobMonitorPage />}

View File

@@ -30,6 +30,9 @@ import {
Building2,
Timer,
BookOpen,
ShoppingCart,
Search,
KeyRound,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useMemo, useState, useEffect } from "react";
@@ -62,6 +65,7 @@ export function Sidebar() {
const s = new Set<string>();
if (location.startsWith("/chart")) s.add("/chart");
if (location.startsWith("/settings")) s.add("/settings");
if (location.startsWith("/dental-shopping")) s.add("/dental-shopping");
return s;
});
@@ -72,6 +76,9 @@ export function Sidebar() {
if (location.startsWith("/settings")) {
setExpandedPaths((prev) => new Set([...prev, "/settings"]));
}
if (location.startsWith("/dental-shopping")) {
setExpandedPaths((prev) => new Set([...prev, "/dental-shopping"]));
}
}, [location]);
const togglePath = (path: string) => {
@@ -162,6 +169,23 @@ export function Sidebar() {
path: "/cloud-storage",
icon: <Cloud className="h-5 w-5 text-sky-500" />,
},
{
name: "AI Dental Shopping",
path: "/dental-shopping",
icon: <ShoppingCart className="h-5 w-5 text-cyan-500" />,
children: [
{
name: "Search / Tag",
path: "/dental-shopping/search-tag",
icon: <Search className="h-4 w-4 text-cyan-400" />,
},
{
name: "Login Info",
path: "/dental-shopping/login-info",
icon: <KeyRound className="h-4 w-4 text-cyan-400" />,
},
],
},
{
name: "Database Management",
path: "/database-management",

View File

@@ -0,0 +1,317 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { useAuth } from "@/hooks/use-auth";
import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
import { KeyRound, Plus, Pencil, Trash2, Eye, EyeOff, ExternalLink } from "lucide-react";
type ShoppingVendor = {
id: number;
userId: number;
vendorName: string;
websiteUrl: string;
loginUsername: string;
loginPassword: string;
};
const QUERY_KEY = ["/api/shopping-vendors/"];
// ─── Modal ─────────────────────────────────────────────────────────────────
function VendorModal({
userId,
defaultValues,
onClose,
}: {
userId: number;
defaultValues?: ShoppingVendor;
onClose: () => void;
}) {
const { toast } = useToast();
const qc = useQueryClient();
const [vendorName, setVendorName] = useState(defaultValues?.vendorName ?? "");
const [websiteUrl, setWebsiteUrl] = useState(defaultValues?.websiteUrl ?? "");
const [loginUsername, setLoginUsername] = useState(defaultValues?.loginUsername ?? "");
const [loginPassword, setLoginPassword] = useState(defaultValues?.loginPassword ?? "");
const [showPassword, setShowPassword] = useState(false);
useEffect(() => {
setVendorName(defaultValues?.vendorName ?? "");
setWebsiteUrl(defaultValues?.websiteUrl ?? "");
setLoginUsername(defaultValues?.loginUsername ?? "");
setLoginPassword(defaultValues?.loginPassword ?? "");
}, [defaultValues]);
const mutation = useMutation({
mutationFn: async () => {
const payload = { vendorName: vendorName.trim(), websiteUrl: websiteUrl.trim(), loginUsername: loginUsername.trim(), loginPassword: loginPassword.trim(), userId };
const url = defaultValues?.id ? `/api/shopping-vendors/${defaultValues.id}` : "/api/shopping-vendors/";
const method = defaultValues?.id ? "PUT" : "POST";
const res = await apiRequest(method, url, payload);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to save vendor");
}
return res.json();
},
onSuccess: () => {
toast({ title: `Vendor ${defaultValues?.id ? "updated" : "added"} successfully.` });
qc.invalidateQueries({ queryKey: QUERY_KEY });
onClose();
},
onError: (err: any) => {
toast({ title: "Error", description: err.message, variant: "destructive" });
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!vendorName || !websiteUrl || !loginUsername || !loginPassword) {
toast({ title: "All fields are required.", variant: "destructive" });
return;
}
mutation.mutate();
};
return (
<div className="fixed inset-0 bg-black/50 flex justify-center items-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-md shadow-xl">
<h2 className="text-lg font-semibold mb-5">
{defaultValues?.id ? "Edit Vendor" : "Add Vendor"}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Vendor Name</label>
<input
type="text"
placeholder="e.g. Net32"
value={vendorName}
onChange={(e) => setVendorName(e.target.value)}
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Website URL</label>
<input
type="url"
placeholder="https://www.net32.com"
value={websiteUrl}
onChange={(e) => setWebsiteUrl(e.target.value)}
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Username / Email</label>
<input
type="text"
placeholder="your@email.com"
value={loginUsername}
onChange={(e) => setLoginUsername(e.target.value)}
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
placeholder="••••••••"
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
className="w-full border rounded-lg px-3 py-2 pr-10 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
<button
type="button"
tabIndex={-1}
onClick={() => setShowPassword((p) => !p)}
className="absolute inset-y-0 right-3 flex items-center text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
disabled={mutation.isPending}
className="text-sm text-gray-500 hover:underline"
>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending}
className="bg-cyan-600 text-white text-sm px-5 py-2 rounded-lg hover:bg-cyan-700 disabled:opacity-50"
>
{mutation.isPending ? "Saving..." : defaultValues?.id ? "Update" : "Add Vendor"}
</button>
</div>
</form>
</div>
</div>
);
}
// ─── Page ──────────────────────────────────────────────────────────────────
export default function DentalShoppingLoginInfoPage() {
const { user } = useAuth();
const { toast } = useToast();
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [editingVendor, setEditingVendor] = useState<ShoppingVendor | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ShoppingVendor | null>(null);
const [revealedIds, setRevealedIds] = useState<Set<number>>(new Set());
const { data: vendors = [], isLoading } = useQuery<ShoppingVendor[]>({
queryKey: QUERY_KEY,
queryFn: async () => {
const res = await apiRequest("GET", "/api/shopping-vendors/");
if (!res.ok) throw new Error("Failed to fetch vendors");
return res.json();
},
});
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
const res = await apiRequest("DELETE", `/api/shopping-vendors/${id}`);
if (!res.ok) throw new Error("Failed to delete vendor");
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QUERY_KEY });
setDeleteTarget(null);
toast({ title: "Vendor deleted." });
},
onError: (err: any) => {
toast({ title: "Error", description: err.message, variant: "destructive" });
},
});
const toggleReveal = (id: number) => {
setRevealedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<div className="p-6 max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-cyan-50">
<KeyRound className="h-6 w-6 text-cyan-600" />
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-800">Login Info</h1>
<p className="text-sm text-gray-500">Manage login credentials for dental shopping websites</p>
</div>
</div>
<Button
onClick={() => { setEditingVendor(null); setModalOpen(true); }}
className="bg-cyan-600 hover:bg-cyan-700 text-white"
>
<Plus className="h-4 w-4 mr-2" /> Add Vendor
</Button>
</div>
{/* Table */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
{isLoading ? (
<div className="py-16 text-center text-gray-400 text-sm">Loading...</div>
) : vendors.length === 0 ? (
<div className="py-16 flex flex-col items-center gap-3 text-center">
<KeyRound className="h-10 w-10 text-gray-300" />
<p className="text-gray-400 text-sm">No vendors saved yet. Click "Add Vendor" to get started.</p>
</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-5 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Vendor</th>
<th className="px-5 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Website</th>
<th className="px-5 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Username / Email</th>
<th className="px-5 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Password</th>
<th className="px-5 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{vendors.map((v) => (
<tr key={v.id} className="hover:bg-gray-50 transition-colors">
<td className="px-5 py-3 font-medium text-gray-800 text-sm">{v.vendorName}</td>
<td className="px-5 py-3 text-sm">
<a
href={v.websiteUrl}
target="_blank"
rel="noopener noreferrer"
className="text-cyan-600 hover:underline flex items-center gap-1"
>
{v.websiteUrl.replace(/^https?:\/\//, "")}
<ExternalLink className="h-3 w-3 flex-shrink-0" />
</a>
</td>
<td className="px-5 py-3 text-sm text-gray-600">{v.loginUsername}</td>
<td className="px-5 py-3 text-sm text-gray-600">
<div className="flex items-center gap-2">
<span className="font-mono">
{revealedIds.has(v.id) ? v.loginPassword : "••••••••"}
</span>
<button
onClick={() => toggleReveal(v.id)}
className="text-gray-400 hover:text-gray-600"
title={revealedIds.has(v.id) ? "Hide password" : "Show password"}
>
{revealedIds.has(v.id) ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
</td>
<td className="px-5 py-3 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => { setEditingVendor(v); setModalOpen(true); }}
>
<Pencil className="h-4 w-4 text-gray-500" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(v)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Add/Edit Modal */}
{modalOpen && user?.id != null && (
<VendorModal
userId={user.id as number}
defaultValues={editingVendor ?? undefined}
onClose={() => { setModalOpen(false); setEditingVendor(null); }}
/>
)}
{/* Delete Confirm */}
<DeleteConfirmationDialog
isOpen={!!deleteTarget}
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
onCancel={() => setDeleteTarget(null)}
entityName={deleteTarget?.vendorName}
/>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { ShoppingBag, Tag, Search } from "lucide-react";
export default function DentalShoppingSearchTagPage() {
return (
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 rounded-lg bg-cyan-50">
<Search className="h-6 w-6 text-cyan-600" />
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-800">Search / Tag</h1>
<p className="text-sm text-gray-500">Compare, tag, and remember dental products</p>
</div>
</div>
<div className="rounded-xl border border-dashed border-gray-300 bg-gray-50 flex flex-col items-center justify-center py-24 text-center">
<div className="flex gap-3 mb-4">
<ShoppingBag className="h-10 w-10 text-cyan-300" />
<Tag className="h-10 w-10 text-cyan-300" />
</div>
<p className="text-gray-400 text-sm">
Search, compare, tag, and save dental products coming soon.
</p>
</div>
</div>
);
}