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:
@@ -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 />}
|
||||
|
||||
@@ -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",
|
||||
|
||||
317
apps/Frontend/src/pages/dental-shopping-login-info-page.tsx
Normal file
317
apps/Frontend/src/pages/dental-shopping-login-info-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
apps/Frontend/src/pages/dental-shopping-search-tag-page.tsx
Normal file
27
apps/Frontend/src/pages/dental-shopping-search-tag-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user