diff --git a/app/client-portal/page.tsx b/app/client-portal/page.tsx new file mode 100644 index 0000000..2474901 --- /dev/null +++ b/app/client-portal/page.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; +import { AppHeader } from "@/components/layout/app-header"; +import { PageHeader } from "@/components/layout/page-header"; +import { AssociateTable } from "@/components/associations/associate-table"; +import { AssociationInfoCard } from "@/components/associations/association-info-card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { getAssociation } from "@/lib/api"; +import type { Association } from "@/lib/models"; + +export default function ClientPortalPage() { + const router = useRouter(); + const [association, setAssociation] = useState(null); + const [loading, setLoading] = useState(true); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!mounted) return; + + const associationId = localStorage.getItem("association_id"); + if (!associationId) { + router.push("/login"); + return; + } + + getAssociation(associationId) + .then(setAssociation) + .catch((error) => { + console.error("Failed to load association:", error); + router.push("/login"); + }) + .finally(() => setLoading(false)); + }, [mounted, router]); + + if (!mounted || loading) { + return ( +
+ +
+ ); + } + + if (!association) { + return null; + } + + return ( +
+ +
+ + + + + + + Soci ({association.associates?.length ?? 0}) + + + + + +
+
+ ); +} diff --git a/app/dashboard/[id]/page.tsx b/app/dashboard/[id]/page.tsx new file mode 100644 index 0000000..65c83fa --- /dev/null +++ b/app/dashboard/[id]/page.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { ArrowLeft, Loader2, Plus, Trash2 } from "lucide-react"; +import { AppHeader } from "@/components/layout/app-header"; +import { PageHeader } from "@/components/layout/page-header"; +import { AssociateFormDialog } from "@/components/associations/associate-form-dialog"; +import { AssociateTable } from "@/components/associations/associate-table"; +import { AssociationInfoCard } from "@/components/associations/association-info-card"; +import { EditAssociationDialog } from "@/components/associations/edit-association-dialog"; +import { UserAccountsCard } from "@/components/associations/user-accounts-card"; +import { UserFormDialog } from "@/components/associations/user-form-dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { deleteAssociation, getAssociation, updateAssociation } from "@/lib/api"; +import type { Associate, Association, User } from "@/lib/models"; + +export default function AssociationDetailPage() { + const router = useRouter(); + const params = useParams(); + const id = params.id as string; + + const [association, setAssociation] = useState(null); + const [loading, setLoading] = useState(true); + + const [associateDialogOpen, setAssociateDialogOpen] = useState(false); + const [editingAssociateIndex, setEditingAssociateIndex] = useState(undefined); + + const [userDialogOpen, setUserDialogOpen] = useState(false); + const [editingUserIndex, setEditingUserIndex] = useState(undefined); + + useEffect(() => { + getAssociation(id) + .then(setAssociation) + .catch((error) => { + console.error("Failed to load association:", error); + router.push("/dashboard"); + }) + .finally(() => setLoading(false)); + }, [id, router]); + + const persist = async (updated: Association) => { + const saved = await updateAssociation(id, updated); + setAssociation(saved); + }; + + const openAddAssociate = () => { + setEditingAssociateIndex(undefined); + setAssociateDialogOpen(true); + }; + + const openEditAssociate = (index: number) => { + setEditingAssociateIndex(index); + setAssociateDialogOpen(true); + }; + + const handleAssociateSubmit = async (associate: Associate) => { + if (!association) return; + const associates = [...(association.associates ?? [])]; + if (editingAssociateIndex !== undefined) { + associates[editingAssociateIndex] = associate; + } else { + associates.push(associate); + } + await persist({ ...association, associates }); + }; + + const handleAssociateDelete = async (index: number) => { + if (!association) return; + const associates = (association.associates ?? []).filter((_, i) => i !== index); + await persist({ ...association, associates }); + }; + + const openAddUser = () => { + setEditingUserIndex(undefined); + setUserDialogOpen(true); + }; + + const openEditUser = (index: number) => { + setEditingUserIndex(index); + setUserDialogOpen(true); + }; + + const handleUserSubmit = async (user: User) => { + if (!association) return; + const users = [...(association.users ?? [])]; + if (editingUserIndex !== undefined) { + const existing = users[editingUserIndex]; + users[editingUserIndex] = { ...user, password: user.password || existing.password }; + } else { + users.push(user); + } + await persist({ ...association, users }); + }; + + const handleUserDelete = async (index: number) => { + if (!association) return; + const users = (association.users ?? []).filter((_, i) => i !== index); + await persist({ ...association, users }); + }; + + const handleDeleteAssociation = async () => { + await deleteAssociation(id); + router.push("/dashboard"); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!association) { + return null; + } + + return ( +
+ +
+
+ + + + + + Elimina + + } + title="Eliminare questa associazione?" + description={`"${association.name}" e tutti i suoi soci verranno eliminati definitivamente. L'operazione non è reversibile.`} + confirmLabel="Elimina definitivamente" + onConfirm={handleDeleteAssociation} + /> + + } + /> +
+ + + + + + Soci ({association.associates?.length ?? 0}) + + + + + + + + + + Aggiungi account + + } + /> +
+ + + + +
+ ); +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..f2c08c7 --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; +import { AppHeader } from "@/components/layout/app-header"; +import { PageHeader } from "@/components/layout/page-header"; +import { AssociationSearch } from "@/components/associations/association-search"; +import { AssociationTable } from "@/components/associations/association-table"; +import { CreateAssociationDialog } from "@/components/associations/create-association-dialog"; +import { getAssociations } from "@/lib/api"; +import type { Association } from "@/lib/models"; + +export default function DashboardPage() { + const router = useRouter(); + const [associations, setAssociations] = useState([]); + const [loading, setLoading] = useState(true); + const [mounted, setMounted] = useState(false); + const [query, setQuery] = useState(""); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!mounted) return; + getAssociations() + .then(setAssociations) + .catch((error: Error) => { + if (error.message === "Unauthorized") { + router.push("/login"); + } else { + console.error(error); + } + }) + .finally(() => setLoading(false)); + }, [mounted, router]); + + const filtered = useMemo(() => { + const query_ = query.trim().toLowerCase(); + if (!query_) return associations; + return associations.filter((association) => + [association.name, association.address, association.vatNumber, association.runtsId].some( + (value) => value?.toLowerCase().includes(query_) + ) + ); + }, [associations, query]); + + if (!mounted || loading) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ router.push(`/dashboard/${association.id}`)} + /> + } + /> + + +
+
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/app/favicon.ico and /dev/null differ diff --git a/app/globals.css b/app/globals.css index a2dc41e..a6168a5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,131 @@ @import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; -:root { - --background: #ffffff; - --foreground: #171717; -} +@custom-variant dark (&:is(.dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.129 0.042 264.695); + --card: oklch(1 0 0); + --card-foreground: oklch(0.129 0.042 264.695); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.129 0.042 264.695); + --primary: oklch(0.447 0.09 149.3); + --primary-foreground: oklch(0.984 0.003 247.858); + --secondary: oklch(0.968 0.007 247.896); + --secondary-foreground: oklch(0.208 0.042 265.755); + --muted: oklch(0.968 0.007 247.896); + --muted-foreground: oklch(0.554 0.046 257.417); + --accent: oklch(0.955 0.025 149.3); + --accent-foreground: oklch(0.325 0.08 149.3); + --destructive: oklch(0.549 0.16 24.5); + --border: oklch(0.929 0.013 255.508); + --input: oklch(0.929 0.013 255.508); + --ring: oklch(0.447 0.09 149.3 / 50%); + --chart-1: oklch(0.447 0.09 149.3); + --chart-2: oklch(0.549 0.16 24.5); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.984 0.003 247.858); + --sidebar-foreground: oklch(0.129 0.042 264.695); + --sidebar-primary: oklch(0.447 0.09 149.3); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.955 0.025 149.3); + --sidebar-accent-foreground: oklch(0.325 0.08 149.3); + --sidebar-border: oklch(0.929 0.013 255.508); + --sidebar-ring: oklch(0.447 0.09 149.3 / 50%); +} + +.dark { + --background: oklch(0.129 0.042 264.695); + --foreground: oklch(0.984 0.003 247.858); + --card: oklch(0.208 0.042 265.755); + --card-foreground: oklch(0.984 0.003 247.858); + --popover: oklch(0.208 0.042 265.755); + --popover-foreground: oklch(0.984 0.003 247.858); + --primary: oklch(0.667 0.11 149.3); + --primary-foreground: oklch(0.129 0.042 264.695); + --secondary: oklch(0.279 0.041 260.031); + --secondary-foreground: oklch(0.984 0.003 247.858); + --muted: oklch(0.279 0.041 260.031); + --muted-foreground: oklch(0.704 0.04 256.788); + --accent: oklch(0.3 0.05 149.3); + --accent-foreground: oklch(0.9 0.04 149.3); + --destructive: oklch(0.704 0.16 24.5); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.667 0.11 149.3 / 50%); + --chart-1: oklch(0.667 0.11 149.3); + --chart-2: oklch(0.704 0.16 24.5); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.208 0.042 265.755); + --sidebar-foreground: oklch(0.984 0.003 247.858); + --sidebar-primary: oklch(0.667 0.11 149.3); + --sidebar-primary-foreground: oklch(0.129 0.042 264.695); + --sidebar-accent: oklch(0.3 0.05 149.3); + --sidebar-accent-foreground: oklch(0.9 0.04 149.3); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.667 0.11 149.3 / 50%); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + overflow-y: scroll; } } -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; -} + diff --git a/app/icon.jpg b/app/icon.jpg new file mode 100644 index 0000000..102753f Binary files /dev/null and b/app/icon.jpg differ diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..fc39233 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "ENCAP - Rete Associativa", + description: "Gestione della rete associativa ENCAP", }; export default function RootLayout({ @@ -23,7 +23,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..14c6e0e --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Image from "next/image"; +import { Loader2, Lock, Mail, TriangleAlert } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { login } from "@/lib/api"; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + const response = await login(email, password); + + localStorage.setItem("user_role", response.user.role); + localStorage.setItem("user_email", response.user.email); + localStorage.setItem("association_id", response.association.id || ""); + + if (response.user.role === "CLIENT") { + router.push("/client-portal"); + } else { + router.push("/dashboard"); + } + } catch { + setError("Email o password non corretti"); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* Tricolor accent bar */} +
+ + + +
+ + {/* Centered login card */} +
+ + + ENCAP +
+

Accedi

+

+ Inserisci le tue credenziali per accedere al portale +

+
+
+ +
+ {error && ( +
+ + {error} +
+ )} + +
+ +
+ + setEmail(e.target.value)} + required + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + required + /> +
+
+ + +
+
+
+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx deleted file mode 100644 index 295f8fd..0000000 --- a/app/page.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import Image from "next/image"; - -export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
- ); -} diff --git a/components.json b/components.json new file mode 100644 index 0000000..44b4685 --- /dev/null +++ b/components.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/components/associations/associate-form-dialog.tsx b/components/associations/associate-form-dialog.tsx new file mode 100644 index 0000000..0c5aebf --- /dev/null +++ b/components/associations/associate-form-dialog.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { Associate } from "@/lib/models"; + +const ROLE_OPTIONS: { value: Associate["role"]; label: string }[] = [ + { value: "PRESIDENT", label: "Presidente" }, + { value: "VICE_PRESIDENT", label: "Vicepresidente" }, + { value: "SECRETARY", label: "Segretario" }, + { value: "ASSOCIATE", label: "Socio" }, +]; + +const EMPTY_ASSOCIATE: Associate = { + name: "", + surname: "", + birthDate: "", + address: "", + fiscalCode: "", + italianCitizen: true, + role: "ASSOCIATE", + residencePermitNumber: "", +}; + +export function AssociateFormDialog({ + open, + onOpenChange, + initialValue, + onSubmit, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + initialValue?: Associate; + onSubmit: (associate: Associate) => Promise | void; +}) { + const [value, setValue] = useState(initialValue ?? EMPTY_ASSOCIATE); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (open) { + setValue(initialValue ?? EMPTY_ASSOCIATE); + } + }, [open, initialValue]); + + const update = (key: K, fieldValue: Associate[K]) => + setValue((prev) => ({ ...prev, [key]: fieldValue })); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + try { + await onSubmit(value); + onOpenChange(false); + } catch (error) { + console.error("Failed to save associate:", error); + alert("Impossibile salvare il socio"); + } finally { + setSaving(false); + } + }; + + return ( + + + + {initialValue ? "Modifica socio" : "Aggiungi socio"} + +
+
+
+ + update("name", e.target.value)} required /> +
+
+ + update("surname", e.target.value)} + required + /> +
+ +
+ + update("birthDate", e.target.value)} + required + /> +
+
+ + +
+ +
+ + update("address", e.target.value)} + required + /> +
+ +
+ + update("fiscalCode", e.target.value.toUpperCase())} + required + /> +
+ + {!value.italianCitizen && ( +
+ + update("residencePermitNumber", e.target.value)} + /> +
+ )} + +
+ update("italianCitizen", checked === true)} + /> + +
+
+ + + + + +
+
+
+ ); +} diff --git a/components/associations/associate-table.tsx b/components/associations/associate-table.tsx new file mode 100644 index 0000000..a3c8713 --- /dev/null +++ b/components/associations/associate-table.tsx @@ -0,0 +1,102 @@ +import { Pencil, Trash2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { Associate } from "@/lib/models"; + +const ROLE_LABELS: Record = { + PRESIDENT: "Presidente", + VICE_PRESIDENT: "Vicepresidente", + SECRETARY: "Segretario", + ASSOCIATE: "Socio", +}; + +export function AssociateTable({ + associates, + onEdit, + onDelete, +}: { + associates: Associate[]; + onEdit?: (index: number) => void; + onDelete?: (index: number) => Promise | void; +}) { + const editable = Boolean(onEdit || onDelete); + + if (associates.length === 0) { + return ( +

+ Nessun socio registrato +

+ ); + } + + return ( + + + + Nome + Ruolo + Codice fiscale + Indirizzo + Cittadinanza + {editable && } + + + + {associates.map((associate, index) => ( + + + {associate.name} {associate.surname} + + + {ROLE_LABELS[associate.role] ?? associate.role} + + + {associate.fiscalCode || "—"} + + + {associate.address || "—"} + + + + {associate.italianCitizen ? "Italiana" : "Straniera"} + + + {editable && ( + +
+ {onEdit && ( + + )} + {onDelete && ( + + + + } + title="Rimuovere il socio?" + description={`${associate.name} ${associate.surname} verrà rimosso dall'associazione.`} + confirmLabel="Rimuovi" + onConfirm={() => onDelete(index)} + /> + )} +
+
+ )} +
+ ))} +
+
+ ); +} diff --git a/components/associations/association-info-card.tsx b/components/associations/association-info-card.tsx new file mode 100644 index 0000000..33d6111 --- /dev/null +++ b/components/associations/association-info-card.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { Association } from "@/lib/models"; +import { AssociationStatusBadge } from "./association-status-badge"; + +export function AssociationInfoCard({ association }: { association: Association }) { + const fields: { label: string; value: ReactNode }[] = [ + { label: "Indirizzo", value: association.address || "—" }, + { label: "Partita IVA", value: association.vatNumber || "—" }, + { label: "Codice RUNTS", value: association.runtsId || "—" }, + { label: "Stato", value: }, + ]; + + return ( + + + Informazioni generali + + +
+ {fields.map((field) => ( +
+
{field.label}
+
{field.value}
+
+ ))} +
+
+
+ ); +} diff --git a/components/associations/association-search.tsx b/components/associations/association-search.tsx new file mode 100644 index 0000000..41390a8 --- /dev/null +++ b/components/associations/association-search.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Search, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +export function AssociationSearch({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + return ( +
+ + onChange(e.target.value)} + placeholder="Cerca per nome, indirizzo, P.IVA o RUNTS..." + className="pl-9 pr-9" + /> + {value && ( + + )} +
+ ); +} diff --git a/components/associations/association-status-badge.tsx b/components/associations/association-status-badge.tsx new file mode 100644 index 0000000..6d530e8 --- /dev/null +++ b/components/associations/association-status-badge.tsx @@ -0,0 +1,16 @@ +import { Badge } from "@/components/ui/badge"; + +export function AssociationStatusBadge({ isShown }: { isShown?: boolean }) { + if (isShown) { + return ( + + Attiva + + ); + } + return ( + + Nascosta + + ); +} diff --git a/components/associations/association-table.tsx b/components/associations/association-table.tsx new file mode 100644 index 0000000..b348e19 --- /dev/null +++ b/components/associations/association-table.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { ChevronRight } from "lucide-react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { Association } from "@/lib/models"; +import { AssociationStatusBadge } from "./association-status-badge"; + +export function AssociationTable({ associations }: { associations: Association[] }) { + const router = useRouter(); + + if (associations.length === 0) { + return ( +
+ Nessuna associazione trovata +
+ ); + } + + return ( +
+ + + + Nome + Indirizzo + P.IVA + RUNTS + Stato + + + + + {associations.map((association) => ( + router.push(`/dashboard/${association.id}`)} + > + {association.name} + + {association.address} + + + {association.vatNumber || "—"} + + + {association.runtsId || "—"} + + + + + + + + + ))} + +
+
+ ); +} diff --git a/components/associations/create-association-dialog.tsx b/components/associations/create-association-dialog.tsx new file mode 100644 index 0000000..1a7ef8c --- /dev/null +++ b/components/associations/create-association-dialog.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useState } from "react"; +import { Loader2, Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { createAssociation } from "@/lib/api"; +import type { Association } from "@/lib/models"; + +export function CreateAssociationDialog({ + onCreated, +}: { + onCreated: (association: Association) => void; +}) { + const [open, setOpen] = useState(false); + const [creating, setCreating] = useState(false); + const [name, setName] = useState(""); + const [address, setAddress] = useState(""); + const [vatNumber, setVatNumber] = useState(""); + const [runtsId, setRuntsId] = useState(""); + + const reset = () => { + setName(""); + setAddress(""); + setVatNumber(""); + setRuntsId(""); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setCreating(true); + + try { + const association = await createAssociation({ + name, + address, + vatNumber, + runtsId, + isShown: true, + associates: [], + users: [], + }); + reset(); + setOpen(false); + onCreated(association); + } catch (error) { + console.error("Failed to create association:", error); + alert("Impossibile creare l'associazione"); + } finally { + setCreating(false); + } + }; + + return ( + + + + + + + Crea nuova associazione + +
+
+
+ + setName(e.target.value)} + placeholder="Associazione Sportiva Roma" + required + /> +
+ +
+ + setAddress(e.target.value)} + placeholder="Via Roma 10, Milano, 20100" + required + /> +
+ +
+ + setVatNumber(e.target.value)} + placeholder="12345678901" + /> +
+ +
+ + setRuntsId(e.target.value)} + placeholder="RUNTS001" + /> +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/components/associations/edit-association-dialog.tsx b/components/associations/edit-association-dialog.tsx new file mode 100644 index 0000000..56ee9cc --- /dev/null +++ b/components/associations/edit-association-dialog.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Loader2, Pencil } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { updateAssociation } from "@/lib/api"; +import type { Association } from "@/lib/models"; + +export function EditAssociationDialog({ + association, + onSaved, +}: { + association: Association; + onSaved: (association: Association) => void; +}) { + const [open, setOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [name, setName] = useState(association.name); + const [address, setAddress] = useState(association.address); + const [vatNumber, setVatNumber] = useState(association.vatNumber ?? ""); + const [runtsId, setRuntsId] = useState(association.runtsId ?? ""); + const [isShown, setIsShown] = useState(association.isShown ?? true); + + useEffect(() => { + if (open) { + setName(association.name); + setAddress(association.address); + setVatNumber(association.vatNumber ?? ""); + setRuntsId(association.runtsId ?? ""); + setIsShown(association.isShown ?? true); + } + }, [open, association]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + try { + const updated = await updateAssociation(association.id!, { + ...association, + name, + address, + vatNumber, + runtsId, + isShown, + }); + onSaved(updated); + setOpen(false); + } catch (error) { + console.error("Failed to update association:", error); + alert("Impossibile salvare le modifiche"); + } finally { + setSaving(false); + } + }; + + return ( + + + + + + + Modifica associazione + +
+
+
+ + setName(e.target.value)} required /> +
+ +
+ + setAddress(e.target.value)} required /> +
+ +
+ + setVatNumber(e.target.value)} /> +
+ +
+ + setRuntsId(e.target.value)} /> +
+ +
+
+ +

+ Le associazioni non attive non compaiono nell'elenco +

+
+ +
+
+ + + + + +
+
+
+ ); +} diff --git a/components/associations/user-accounts-card.tsx b/components/associations/user-accounts-card.tsx new file mode 100644 index 0000000..1376534 --- /dev/null +++ b/components/associations/user-accounts-card.tsx @@ -0,0 +1,75 @@ +import { Pencil, Trash2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import type { User } from "@/lib/models"; + +const ROLE_LABELS: Record = { + ADMIN: "Amministratore", + MANAGER: "Gestore", + CLIENT: "Associazione", +}; + +export function UserAccountsCard({ + users, + actions, + onEdit, + onDelete, +}: { + users: User[]; + actions?: React.ReactNode; + onEdit?: (index: number) => void; + onDelete?: (index: number) => Promise | void; +}) { + const editable = Boolean(onEdit || onDelete); + + return ( + + + Account di accesso ({users.length}) + {actions} + + + {users.length === 0 ? ( +

+ Nessun account configurato +

+ ) : ( +
    + {users.map((user, index) => ( +
  • +
    + {user.email} + {ROLE_LABELS[user.role] ?? user.role} +
    + {editable && ( +
    + {onEdit && ( + + )} + {onDelete && ( + + + + } + title="Rimuovere l'account?" + description={`${user.email} non potrà più accedere al portale.`} + confirmLabel="Rimuovi" + onConfirm={() => onDelete(index)} + /> + )} +
    + )} +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/components/associations/user-form-dialog.tsx b/components/associations/user-form-dialog.tsx new file mode 100644 index 0000000..b9e48df --- /dev/null +++ b/components/associations/user-form-dialog.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { User } from "@/lib/models"; + +const ROLE_OPTIONS: { value: User["role"]; label: string }[] = [ + { value: "CLIENT", label: "Associazione" }, + { value: "MANAGER", label: "Gestore" }, + { value: "ADMIN", label: "Amministratore" }, +]; + +const EMPTY_USER: User = { email: "", password: "", role: "CLIENT" }; + +export function UserFormDialog({ + open, + onOpenChange, + initialValue, + onSubmit, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + initialValue?: User; + onSubmit: (user: User) => Promise | void; +}) { + const [value, setValue] = useState(initialValue ?? EMPTY_USER); + const [saving, setSaving] = useState(false); + const isEdit = Boolean(initialValue); + + useEffect(() => { + if (open) { + setValue(initialValue ?? EMPTY_USER); + } + }, [open, initialValue]); + + const update = (key: K, fieldValue: User[K]) => + setValue((prev) => ({ ...prev, [key]: fieldValue })); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + try { + await onSubmit(value); + onOpenChange(false); + } catch (error) { + console.error("Failed to save user:", error); + alert("Impossibile salvare l'account"); + } finally { + setSaving(false); + } + }; + + return ( + + + + {isEdit ? "Modifica account" : "Nuovo account di accesso"} + +
+
+ + update("email", e.target.value)} + required + /> +
+ +
+ + update("password", e.target.value)} + placeholder={isEdit ? "Lascia vuoto per non modificarla" : undefined} + required={!isEdit} + /> +
+ +
+ + +
+ + + + + +
+
+
+ ); +} diff --git a/components/layout/app-header.tsx b/components/layout/app-header.tsx new file mode 100644 index 0000000..6a9b888 --- /dev/null +++ b/components/layout/app-header.tsx @@ -0,0 +1,78 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { LogOut } from "lucide-react"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { clearStoredUser, useStoredUser } from "@/lib/auth"; + +const ROLE_LABELS: Record = { + ADMIN: "Amministratore", + MANAGER: "Gestore", + CLIENT: "Associazione", +}; + +export function AppHeader() { + const router = useRouter(); + const { user } = useStoredUser(); + + const handleLogout = () => { + clearStoredUser(); + router.push("/login"); + }; + + const homeHref = user?.role === "CLIENT" ? "/client-portal" : "/dashboard"; + + return ( +
+
+ + ENCAP + ENCAP + + + {user && ( + + + + {user.email} + + + {user.email.slice(0, 2).toUpperCase()} + + + + + {user.email} + + {ROLE_LABELS[user.role] ?? user.role} + + + + + + Esci + + + + )} +
+
+ ); +} diff --git a/components/layout/page-header.tsx b/components/layout/page-header.tsx new file mode 100644 index 0000000..0274153 --- /dev/null +++ b/components/layout/page-header.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; + +export function PageHeader({ + title, + description, + actions, +}: { + title: string; + description?: string; + actions?: ReactNode; +}) { + return ( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..dad3273 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,196 @@ +"use client" + +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..ea65850 --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,109 @@ +"use client" + +import * as React from "react" +import { Avatar as AvatarPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarBadge, + AvatarGroup, + AvatarGroupCount, +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..6eb2a05 --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90", + outline: + "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + link: "text-primary underline-offset-4 [a&]:hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..b5ea4ab --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,64 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..681ad98 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx new file mode 100644 index 0000000..f5a7e43 --- /dev/null +++ b/components/ui/checkbox.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" +import { CheckIcon } from "lucide-react" +import { Checkbox as CheckboxPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/components/ui/confirm-dialog.tsx b/components/ui/confirm-dialog.tsx new file mode 100644 index 0000000..26efbf0 --- /dev/null +++ b/components/ui/confirm-dialog.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { Loader2 } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; + +export function ConfirmDialog({ + trigger, + title, + description, + confirmLabel = "Conferma", + loadingLabel = "Attendere...", + destructive = true, + onConfirm, +}: { + trigger: ReactNode; + title: string; + description: string; + confirmLabel?: string; + loadingLabel?: string; + destructive?: boolean; + onConfirm: () => Promise | void; +}) { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + + const handleConfirm = async () => { + setLoading(true); + try { + await onConfirm(); + setOpen(false); + } catch (error) { + console.error(error); + alert("Si è verificato un errore. Riprova."); + } finally { + setLoading(false); + } + }; + + return ( + + {trigger} + + + {title} + {description} + + + Annulla + { + e.preventDefault(); + handleConfirm(); + }} + > + {loading && } + {loading ? loadingLabel : confirmLabel} + + + + + ); +} diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..80d7ad6 --- /dev/null +++ b/components/ui/dialog.tsx @@ -0,0 +1,158 @@ +"use client" + +import * as React from "react" +import { XIcon } from "lucide-react" +import { Dialog as DialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..ae1fcf6 --- /dev/null +++ b/components/ui/dropdown-menu.tsx @@ -0,0 +1,257 @@ +"use client" + +import * as React from "react" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/components/ui/form.tsx b/components/ui/form.tsx new file mode 100644 index 0000000..b438928 --- /dev/null +++ b/components/ui/form.tsx @@ -0,0 +1,167 @@ +"use client" + +import * as React from "react" +import type { Label as LabelPrimitive } from "radix-ui" +import { Slot } from "radix-ui" +import { + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState } = useFormContext() + const formState = useFormState({ name: fieldContext.name }) + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId() + + return ( + +
+ + ) +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField() + + return ( +