Compare commits
21 Commits
61a277151c
...
master
Author | SHA1 | Date | |
---|---|---|---|
d3b792dc1d | |||
6ff62ca0a6 | |||
04a6322b55 | |||
02628e02ab | |||
eabf5fd836 | |||
70b99228e6 | |||
3272c83f09 | |||
cb00826b16 | |||
91b947c608 | |||
52b8a4c1cb | |||
251d74cc05 | |||
c21ae7452b | |||
f070bb0175 | |||
c68a0d105a | |||
9e5080591a | |||
4adf59d440 | |||
2989cdd421 | |||
40130b65e5 | |||
17486bd00c | |||
46ddc6d7f4 | |||
a575ccd3a1 |
17
.env.example
17
.env.example
@ -1,17 +0,0 @@
|
|||||||
# When adding additional environment variables, the schema in "/src/env.js"
|
|
||||||
# should be updated accordingly.
|
|
||||||
|
|
||||||
# Examples:
|
|
||||||
# SERVERVAR="foo"
|
|
||||||
# NEXT_PUBLIC_CLIENTVAR="bar"
|
|
||||||
|
|
||||||
# Drizzle
|
|
||||||
## WAN
|
|
||||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/example_db"
|
|
||||||
## LAN
|
|
||||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/example_db"
|
|
||||||
|
|
||||||
# Auth.js
|
|
||||||
# openssl rand -base64 33
|
|
||||||
AUTH_SECRET=""
|
|
||||||
# Auth.js Providers:
|
|
0
.eslintrc.cjs
Normal file → Executable file
0
.eslintrc.cjs
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
24
README.md
Normal file → Executable file
24
README.md
Normal file → Executable file
@ -1,3 +1,27 @@
|
|||||||
# Rent Portal
|
# Rent Portal
|
||||||
|
|
||||||
## Portal for tenants to pay rent
|
## Portal for tenants to pay rent
|
||||||
|
|
||||||
|
#### To Do:
|
||||||
|
|
||||||
|
- [ ] Fix First Sign In Form to reload auth session after updating user's name & pfp
|
||||||
|
- [ ] Maybe add additional option to upload PFP
|
||||||
|
- [ ] Break Home Page into components
|
||||||
|
- [ ] Move necessary components from page to layout
|
||||||
|
- [ ] Add necessary pages & set up routing for each link
|
||||||
|
- [ ] Add all necessary tables to database schema.
|
||||||
|
- [ ] Write functions to access data from database
|
||||||
|
- [ ] Add Admin Portal to Avatar Popover, conditionally render based on user. (Probably email)
|
||||||
|
|
||||||
|
#### Questions:
|
||||||
|
|
||||||
|
- How will the data get into the database?
|
||||||
|
- How will the portal know which property the user is renting?
|
||||||
|
- Should the user select the property from a dropdown list? Probably not...
|
||||||
|
- Admin Portal? Admin Privileges?
|
||||||
|
- Does the breadcrumb make sense? Will there be any nested pages?
|
||||||
|
- If it doesn't, then what should go up there?
|
||||||
|
- What needs to be done before we can start building the dashboard?
|
||||||
|
- Properties need to be tied to a user/users.
|
||||||
|
- Payments & Payment Methods need to be tied to users.
|
||||||
|
- Workorders need to be tied to users.
|
||||||
|
0
components.json
Normal file → Executable file
0
components.json
Normal file → Executable file
0
drizzle.config.ts
Normal file → Executable file
0
drizzle.config.ts
Normal file → Executable file
0
next.config.js
Normal file → Executable file
0
next.config.js
Normal file → Executable file
29
package.json
Normal file → Executable file
29
package.json
Normal file → Executable file
@ -13,17 +13,32 @@
|
|||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"generate-apple-secret": "tsx src/scripts/generate_apple_secret.ts",
|
"generate-apple-secret": "tsx src/scripts/generate_apple_secret.ts",
|
||||||
"go": "git pull && next dev",
|
"go": "next dev",
|
||||||
"goprod": "git pull && next build && next start"
|
"goprod": "next build && next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/drizzle-adapter": "^1.4.2",
|
"@auth/drizzle-adapter": "^1.4.2",
|
||||||
|
"@hookform/resolvers": "^3.9.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
|
"@radix-ui/react-checkbox": "^1.1.1",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.0",
|
||||||
|
"@radix-ui/react-popover": "^1.1.1",
|
||||||
|
"@radix-ui/react-progress": "^1.1.0",
|
||||||
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
|
"@radix-ui/react-slider": "^1.2.1",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
|
"@radix-ui/react-toast": "^1.2.1",
|
||||||
"@t3-oss/env-nextjs": "^0.10.1",
|
"@t3-oss/env-nextjs": "^0.10.1",
|
||||||
"@types/jsonwebtoken": "^9.0.6",
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "1.0.0",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"drizzle-orm": "^0.30.10",
|
"drizzle-orm": "^0.30.10",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
@ -34,9 +49,15 @@
|
|||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"postgres": "^3.4.4",
|
"postgres": "^3.4.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
"react-day-picker": "8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hook-form": "^7.52.2",
|
||||||
|
"recharts": "^2.13.3",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
|
"sonner": "^1.5.0",
|
||||||
"tailwind-merge": "^2.4.0",
|
"tailwind-merge": "^2.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"vaul": "^0.9.1",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -53,9 +74,9 @@
|
|||||||
"postcss": "^8.4.41",
|
"postcss": "^8.4.41",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||||
"tailwindcss": "^3.4.8",
|
"tailwindcss": "^3.4.9",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.16.5",
|
"tsx": "^4.17.0",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
|
1689
pnpm-lock.yaml
generated
Normal file → Executable file
1689
pnpm-lock.yaml
generated
Normal file → Executable file
File diff suppressed because it is too large
Load Diff
0
postcss.config.cjs
Normal file → Executable file
0
postcss.config.cjs
Normal file → Executable file
0
prettier.config.js
Normal file → Executable file
0
prettier.config.js
Normal file → Executable file
0
public/favicon.ico
Normal file → Executable file
0
public/favicon.ico
Normal file → Executable file
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
0
public/logos/Apple_logo_black.svg
Normal file → Executable file
0
public/logos/Apple_logo_black.svg
Normal file → Executable file
Before Width: | Height: | Size: 660 B After Width: | Height: | Size: 660 B |
0
public/logos/Apple_logo_grey.svg
Normal file → Executable file
0
public/logos/Apple_logo_grey.svg
Normal file → Executable file
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
148
src/app/account/billtracker/page.tsx
Normal file
148
src/app/account/billtracker/page.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
|
import { Calendar } from "~/components/ui/calendar"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } 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 { Plus, DollarSign } from 'lucide-react'
|
||||||
|
|
||||||
|
// Mock data for bills
|
||||||
|
const initialBills = [
|
||||||
|
{ id: 1, name: 'Electricity', amount: 80, dueDate: new Date(2023, 6, 15), category: 'Utilities' },
|
||||||
|
{ id: 2, name: 'Internet', amount: 60, dueDate: new Date(2023, 6, 20), category: 'Utilities' },
|
||||||
|
{ id: 3, name: 'Water', amount: 40, dueDate: new Date(2023, 6, 25), category: 'Utilities' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function BillTrackerPage() {
|
||||||
|
const [bills, setBills] = useState(initialBills)
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date | undefined>(new Date())
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||||
|
|
||||||
|
const handleAddBill = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const formData = new FormData(event.currentTarget)
|
||||||
|
const newBill = {
|
||||||
|
id: bills.length + 1,
|
||||||
|
name: formData.get('billName') as string,
|
||||||
|
amount: Number(formData.get('amount')),
|
||||||
|
dueDate: selectedDate as Date,
|
||||||
|
category: formData.get('category') as string,
|
||||||
|
}
|
||||||
|
setBills([...bills, newBill])
|
||||||
|
setIsDialogOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDayContent = (day: Date | undefined) => {
|
||||||
|
if (!day) return null;
|
||||||
|
const dayBills = bills.filter(bill =>
|
||||||
|
bill.dueDate.getDate() === day.getDate() &&
|
||||||
|
bill.dueDate.getMonth() === day.getMonth() &&
|
||||||
|
bill.dueDate.getFullYear() === day.getFullYear()
|
||||||
|
)
|
||||||
|
return dayBills.length > 0 ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<div className="h-2 w-2 bg-primary rounded-full" />
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<CardTitle className="text-2xl">Bill Tracker</CardTitle>
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Add Bill
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<form onSubmit={handleAddBill}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add New Bill</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter the details of the bill you want to track.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="billName" className="text-right">
|
||||||
|
Bill Name
|
||||||
|
</Label>
|
||||||
|
<Input id="billName" name="billName" className="col-span-3" required />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="amount" className="text-right">
|
||||||
|
Amount
|
||||||
|
</Label>
|
||||||
|
<Input id="amount" name="amount" type="number" className="col-span-3" required />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="category" className="text-right">
|
||||||
|
Category
|
||||||
|
</Label>
|
||||||
|
<Select name="category" required>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="Select category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Utilities">Utilities</SelectItem>
|
||||||
|
<SelectItem value="Rent">Rent</SelectItem>
|
||||||
|
<SelectItem value="Insurance">Insurance</SelectItem>
|
||||||
|
<SelectItem value="Subscriptions">Subscriptions</SelectItem>
|
||||||
|
<SelectItem value="Other">Other</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit">Add Bill</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={selectedDate}
|
||||||
|
onSelect={setSelectedDate}
|
||||||
|
className="rounded-md border"
|
||||||
|
components={{
|
||||||
|
DayContent: ({ date }) => getDayContent(date),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Upcoming Bills</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{bills.map((bill) => (
|
||||||
|
<li key={bill.id} className="flex justify-between items-center p-2 bg-muted rounded-md">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{bill.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{bill.dueDate.toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<DollarSign className="h-4 w-4 mr-1 text-primary" />
|
||||||
|
<span className="font-semibold">{bill.amount.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
19
src/app/account/billtracker/page.tsx.bak
Executable file
19
src/app/account/billtracker/page.tsx.bak
Executable file
@ -0,0 +1,19 @@
|
|||||||
|
"use server"
|
||||||
|
import { auth } from "~/auth"
|
||||||
|
import BreadCrumbBillTracker from "~/components/portal/home/breadcrumb/BreadCrumbBillTracker"
|
||||||
|
import BillTrackerCalendar from "~/components/portal/billtracker/BillTrackerCalendar"
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user) return <></>
|
||||||
|
return (
|
||||||
|
<div className="w-2/3 flex flex-col p-6">
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<div className="">
|
||||||
|
<BreadCrumbBillTracker />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
< BillTrackerCalendar />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
63
src/app/account/documents/page.tsx
Normal file
63
src/app/account/documents/page.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"
|
||||||
|
import { FileText, Download, Upload } from 'lucide-react'
|
||||||
|
|
||||||
|
// Mock data for documents
|
||||||
|
const documents = [
|
||||||
|
{ id: 1, name: 'Lease Agreement', type: 'PDF', size: '2.5 MB', date: '2023-01-15' },
|
||||||
|
{ id: 2, name: 'Move-in Checklist', type: 'DOCX', size: '1.2 MB', date: '2023-01-15' },
|
||||||
|
{ id: 3, name: 'Rent Payment Receipt - June 2023', type: 'PDF', size: '0.5 MB', date: '2023-06-01' },
|
||||||
|
{ id: 4, name: 'Property Rules and Regulations', type: 'PDF', size: '1.8 MB', date: '2023-01-15' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function DocumentsPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<CardTitle className="text-2xl">Documents</CardTitle>
|
||||||
|
<Button>
|
||||||
|
<Upload className="mr-2 h-4 w-4" /> Upload Document
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Size</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Action</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{documents.map((doc) => (
|
||||||
|
<TableRow key={doc.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
{doc.name}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{doc.type}</TableCell>
|
||||||
|
<TableCell>{doc.size}</TableCell>
|
||||||
|
<TableCell>{doc.date}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Download className="mr-2 h-4 w-4" /> Download
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
59
src/app/account/layout.tsx
Normal file
59
src/app/account/layout.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Card, CardContent } from "~/components/ui/card"
|
||||||
|
import { CreditCard, FileText, MessageSquare, PenToolIcon as Tool, BarChart } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function AccountLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-8 text-primary">My Account</h1>
|
||||||
|
<div className="flex flex-col md:flex-row gap-8">
|
||||||
|
<Card className="w-full md:w-64 h-fit">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<nav className="space-y-2">
|
||||||
|
<Link href="/account" passHref>
|
||||||
|
<Button variant="ghost" className="w-full justify-start">
|
||||||
|
<BarChart className="mr-2 h-4 w-4" /> Dashboard
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/account/payments" passHref>
|
||||||
|
<Button variant="ghost" className="w-full justify-start">
|
||||||
|
<CreditCard className="mr-2 h-4 w-4" /> Payments
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/account/workorders" passHref>
|
||||||
|
<Button variant="ghost" className="w-full justify-start">
|
||||||
|
<Tool className="mr-2 h-4 w-4" /> Work Orders
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/account/messages" passHref>
|
||||||
|
<Button variant="ghost" className="w-full justify-start">
|
||||||
|
<MessageSquare className="mr-2 h-4 w-4" /> Messages
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/account/documents" passHref>
|
||||||
|
<Button variant="ghost" className="w-full justify-start">
|
||||||
|
<FileText className="mr-2 h-4 w-4" /> Documents
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/account/billtracker" passHref>
|
||||||
|
<Button variant="ghost" className="w-full justify-start">
|
||||||
|
<BarChart className="mr-2 h-4 w-4" /> Bill Tracker
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<main className="flex-1">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
70
src/app/account/messages/page.tsx
Normal file
70
src/app/account/messages/page.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Avatar, AvatarFallback } from "~/components/ui/avatar"
|
||||||
|
|
||||||
|
// Mock data for messages
|
||||||
|
const initialMessages = [
|
||||||
|
{ id: 1, sender: 'Property Manager', content: 'Your maintenance request has been received and scheduled for next Tuesday.', timestamp: '2023-06-20T10:30:00Z' },
|
||||||
|
{ id: 2, sender: 'Tenant', content: 'Thank you for the quick response. I'll make sure to be available on Tuesday.', timestamp: '2023-06-20T11:15:00Z' },
|
||||||
|
{ id: 3, sender: 'Property Manager', content: 'Great! The maintenance team will arrive between 9 AM and 12 PM. Please ensure they have access to the affected area.', timestamp: '2023-06-20T14:00:00Z' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function MessagesPage() {
|
||||||
|
const [messages, setMessages] = useState(initialMessages)
|
||||||
|
const [newMessage, setNewMessage] = useState('')
|
||||||
|
|
||||||
|
const handleSendMessage = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (newMessage.trim()) {
|
||||||
|
const message = {
|
||||||
|
id: messages.length + 1,
|
||||||
|
sender: 'Tenant',
|
||||||
|
content: newMessage,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
setMessages([...messages, message])
|
||||||
|
setNewMessage('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl">Messages</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<div key={message.id} className={`flex ${message.sender === 'Tenant' ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
<div className={`flex ${message.sender === 'Tenant' ? 'flex-row-reverse' : 'flex-row'} items-start space-x-2`}>
|
||||||
|
<Avatar className="w-10 h-10">
|
||||||
|
<AvatarFallback>{message.sender[0]}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className={`rounded-lg p-3 ${message.sender === 'Tenant' ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>
|
||||||
|
<p className="text-sm font-medium">{message.sender}</p>
|
||||||
|
<p className="mt-1">{message.content}</p>
|
||||||
|
<p className="mt-1 text-xs opacity-70">{new Date(message.timestamp).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSendMessage} className="mt-4 flex space-x-2">
|
||||||
|
<Input
|
||||||
|
value={newMessage}
|
||||||
|
onChange={(e) => setNewMessage(e.target.value)}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
className="flex-grow"
|
||||||
|
/>
|
||||||
|
<Button type="submit">Send</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
112
src/app/account/page.tsx
Normal file
112
src/app/account/page.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Progress } from "~/components/ui/progress"
|
||||||
|
import { CreditCard, AlertTriangle, CheckCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function AccountHomePage() {
|
||||||
|
// This data would typically come from your backend
|
||||||
|
const accountData = {
|
||||||
|
balance: 1200,
|
||||||
|
nextPaymentDue: "2023-07-01",
|
||||||
|
recentPayment: {
|
||||||
|
amount: 1200,
|
||||||
|
date: "2023-06-01"
|
||||||
|
},
|
||||||
|
workOrders: [
|
||||||
|
{ id: 1, title: "Leaky faucet", status: "In Progress" },
|
||||||
|
{ id: 2, title: "Broken AC", status: "Scheduled" }
|
||||||
|
],
|
||||||
|
documents: [
|
||||||
|
{ id: 1, title: "Lease Agreement", date: "2023-01-15" },
|
||||||
|
{ id: 2, title: "Move-in Checklist", date: "2023-01-15" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Account Overview</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Current Balance</CardTitle>
|
||||||
|
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">${accountData.balance}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Next payment due: {accountData.nextPaymentDue}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Recent Payment</CardTitle>
|
||||||
|
<CheckCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">${accountData.recentPayment.amount}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Paid on: {accountData.recentPayment.date}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Lease Progress</CardTitle>
|
||||||
|
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Progress value={33} className="w-full" />
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
4 months remaining
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Work Orders</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{accountData.workOrders.map(order => (
|
||||||
|
<li key={order.id} className="flex justify-between items-center">
|
||||||
|
<span>{order.title}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">{order.status}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Button className="w-full mt-4">View All Work Orders</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Documents</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{accountData.documents.map(doc => (
|
||||||
|
<li key={doc.id} className="flex justify-between items-center">
|
||||||
|
<span>{doc.title}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">{doc.date}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Button className="w-full mt-4">View All Documents</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
80
src/app/account/payments/page.tsx
Normal file
80
src/app/account/payments/page.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"
|
||||||
|
import { CreditCard, DollarSign } from 'lucide-react'
|
||||||
|
|
||||||
|
// This would typically come from your database
|
||||||
|
const paymentHistory = [
|
||||||
|
{ id: 1, date: '2023-06-01', amount: 1200, status: 'Paid' },
|
||||||
|
{ id: 2, date: '2023-05-01', amount: 1200, status: 'Paid' },
|
||||||
|
{ id: 3, date: '2023-04-01', amount: 1200, status: 'Paid' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function PaymentsPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl">Current Balance</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-4xl font-bold">$1,200.00</div>
|
||||||
|
<p className="text-muted-foreground mt-2">Due on July 1, 2023</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button className="w-full">
|
||||||
|
<DollarSign className="mr-2 h-4 w-4" /> Make a Payment
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Payment Methods</CardTitle>
|
||||||
|
<CardDescription>Manage your payment methods</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<CreditCard className="mr-2 h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Visa ending in 1234</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Expires 12/2025</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline">Edit</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button variant="outline" className="w-full">Add Payment Method</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Payment History</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Amount</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{paymentHistory.map((payment) => (
|
||||||
|
<TableRow key={payment.id}>
|
||||||
|
<TableCell>{payment.date}</TableCell>
|
||||||
|
<TableCell>${payment.amount.toFixed(2)}</TableCell>
|
||||||
|
<TableCell>{payment.status}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
135
src/app/account/workorders/page.tsx
Normal file
135
src/app/account/workorders/page.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Label } from "~/components/ui/label"
|
||||||
|
import { Textarea } from "~/components/ui/textarea"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"
|
||||||
|
import { PenToolIcon as Tool, Plus } from 'lucide-react'
|
||||||
|
|
||||||
|
// This would typically come from your database
|
||||||
|
const workOrders = [
|
||||||
|
{ id: 1, date: '2023-06-15', issue: 'Leaky faucet', status: 'In Progress' },
|
||||||
|
{ id: 2, date: '2023-06-10', issue: 'Broken AC', status: 'Scheduled' },
|
||||||
|
{ id: 3, date: '2023-05-28', issue: 'Clogged drain', status: 'Completed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function WorkOrdersPage() {
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
// Here you would typically send the form data to your server
|
||||||
|
setIsDialogOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<CardTitle className="text-2xl">Work Orders</CardTitle>
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> New Work Order
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Work Order</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Describe the issue you're experiencing. We'll get on it as soon as possible.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="issue" className="text-right">
|
||||||
|
Issue
|
||||||
|
</Label>
|
||||||
|
<Input id="issue" className="col-span-3" required />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="location" className="text-right">
|
||||||
|
Location
|
||||||
|
</Label>
|
||||||
|
<Select required>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="Select location" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="kitchen">Kitchen</SelectItem>
|
||||||
|
<SelectItem value="bathroom">Bathroom</SelectItem>
|
||||||
|
<SelectItem value="bedroom">Bedroom</SelectItem>
|
||||||
|
<SelectItem value="livingroom">Living Room</SelectItem>
|
||||||
|
<SelectItem value="other">Other</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="description" className="text-right">
|
||||||
|
Description
|
||||||
|
</Label>
|
||||||
|
<Textarea id="description" className="col-span-3" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit">Submit Work Order</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Issue</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Action</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{workOrders.map((order) => (
|
||||||
|
<TableRow key={order.id}>
|
||||||
|
<TableCell>{order.date}</TableCell>
|
||||||
|
<TableCell>{order.issue}</TableCell>
|
||||||
|
<TableCell>{order.status}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="outline" size="sm">View Details</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Maintenance Tips</CardTitle>
|
||||||
|
<CardDescription>Keep your living space in top condition with these tips</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="list-disc pl-4 space-y-2">
|
||||||
|
<li>Regularly clean or replace HVAC filters</li>
|
||||||
|
<li>Check and clean gutters seasonally</li>
|
||||||
|
<li>Test smoke and carbon monoxide detectors monthly</li>
|
||||||
|
<li>Inspect plumbing fixtures for leaks</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button variant="link" className="w-full">View More Tips</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
223
src/app/admin/documents/page.tsx
Normal file
223
src/app/admin/documents/page.tsx
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog"
|
||||||
|
import { Label } from "~/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
|
||||||
|
import { Badge } from "~/components/ui/badge"
|
||||||
|
import { Search, Plus, FileText, Download, Trash2, Share2, Upload } from 'lucide-react'
|
||||||
|
|
||||||
|
// Mock data for documents
|
||||||
|
const documents = [
|
||||||
|
{ id: 1, name: "Lease Agreement - John Doe", type: "PDF", size: "2.5 MB", date: "2023-07-01", status: "Active" },
|
||||||
|
{ id: 2, name: "Rent Receipt - Jane Smith", type: "PDF", size: "1.2 MB", date: "2023-07-02", status: "Archived" },
|
||||||
|
{ id: 3, name: "Maintenance Report - Riverside Condos", type: "DOCX", size: "3.7 MB", date: "2023-07-03", status: "Active" },
|
||||||
|
{ id: 4, name: "Property Rules - Sunset Apartments", type: "PDF", size: "1.8 MB", date: "2023-07-04", status: "Active" },
|
||||||
|
// Add more mock data as needed
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function DocumentsPage() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState("All")
|
||||||
|
|
||||||
|
const filteredDocuments = documents.filter(doc =>
|
||||||
|
doc.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
|
||||||
|
(selectedStatus === "All" || doc.status === selectedStatus)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<CardTitle className="text-2xl">Documents</CardTitle>
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Upload Document
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Upload New Document</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Upload a new document and assign it to tenants or properties.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="document" className="text-right">
|
||||||
|
Document
|
||||||
|
</Label>
|
||||||
|
<Input id="document" type="file" className="col-span-3" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="name" className="text-right">
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Input id="name" className="col-span-3" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="assign" className="text-right">
|
||||||
|
Assign To
|
||||||
|
</Label>
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="Select recipients" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Tenants</SelectItem>
|
||||||
|
<SelectItem value="sunset">Sunset Apartments</SelectItem>
|
||||||
|
<SelectItem value="oakwood">Oakwood Residences</SelectItem>
|
||||||
|
<SelectItem value="riverside">Riverside Condos</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit">Upload Document</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Search documents..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-[300px]"
|
||||||
|
/>
|
||||||
|
<Search className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Filter by status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="All">All Statuses</SelectItem>
|
||||||
|
<SelectItem value="Active">Active</SelectItem>
|
||||||
|
<SelectItem value="Archived">Archived</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Size</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredDocuments.map((doc) => (
|
||||||
|
<TableRow key={doc.id}>
|
||||||
|
<TableCell className="font-medium">{doc.name}</TableCell>
|
||||||
|
<TableCell>{doc.type}</TableCell>
|
||||||
|
<TableCell>{doc.size}</TableCell>
|
||||||
|
<TableCell>{doc.date}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={doc.status === 'Active' ? 'default' : 'secondary'}>
|
||||||
|
{doc.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Share2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Document Statistics</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs defaultValue="overview">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value="usage">Usage</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="overview">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Total Documents
|
||||||
|
</CardTitle>
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{documents.length}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Active Documents
|
||||||
|
</CardTitle>
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{documents.filter(doc => doc.status === 'Active').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Total Storage Used
|
||||||
|
</CardTitle>
|
||||||
|
<Upload className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">9.2 MB</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Most Common Type
|
||||||
|
</CardTitle>
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">PDF</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="usage">
|
||||||
|
<p>Document usage statistics and charts will be displayed here.</p>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
62
src/app/admin/layout.tsx
Normal file
62
src/app/admin/layout.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Card, CardContent } from "~/components/ui/card"
|
||||||
|
import { CreditCard, FileText, MessageSquare, PenToolIcon as Tool, BarChart, Users, Home } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-8 text-primary">Admin Dashboard</h1>
|
||||||
|
<div className="flex flex-col md:flex-row gap-8">
|
||||||
|
<Card className="w-full md:w-64 h-fit">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<nav className="space-y-2">
|
||||||
|
<Link href="/admin" passHref>
|
||||||
|
<Button variant="ghost" className="w-full justify-start">
|
||||||
|
<BarChart className="mr-2 h-4 w-4" /> Dashboard
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/tenants" passHref>
|
||||||
|
<Button variant="ghost" className="w-full justify-start">
|
||||||
|
<Users className="mr-2 h-4 w-4" /> Tenants
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/properties" passHref>
|
||||||
|
<Button variant="ghost" className="w-full justify-start">
|
||||||
|
<Home className="mr-2 h-4 w-4" /> Properties
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/payments" passHref>
|
||||||
|
<Button variant="ghost" className="w-full justify-start">
|
||||||
|
<CreditCard className="mr-2 h-4 w-4" /> Payments
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/workorders" passHref>
|
||||||
|
<Button variant="ghost" className="w-full justify-start">
|
||||||
|
<Tool className="mr-2 h-4 w-4" /> Work Orders
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/messages" passHref>
|
||||||
|
<Button variant="ghost" className="w-full justify-start">
|
||||||
|
<MessageSquare className="mr-2 h-4 w-4" /> Messages
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/documents" passHref>
|
||||||
|
<Button variant="ghost" className="w-full justify-start">
|
||||||
|
<FileText className="mr-2 h-4 w-4" /> Documents
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<main className="flex-1">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
249
src/app/admin/messages/page.tsx
Normal file
249
src/app/admin/messages/page.tsx
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog"
|
||||||
|
import { Label } from "~/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"
|
||||||
|
import { Textarea } from "~/components/ui/textarea"
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
|
||||||
|
import { Badge } from "~/components/ui/badge"
|
||||||
|
import { Search, Plus, MessageSquare, Users, ArrowUpRight } from 'lucide-react'
|
||||||
|
|
||||||
|
// Mock data for messages
|
||||||
|
const messages = [
|
||||||
|
{ id: 1, tenant: "John Doe", property: "Sunset Apartments, Apt 4B", subject: "Maintenance Request", date: "2023-07-05", status: "Unread" },
|
||||||
|
{ id: 2, tenant: "Jane Smith", property: "Oakwood Residences, Apt 2A", subject: "Rent Inquiry", date: "2023-07-04", status: "Read" },
|
||||||
|
{ id: 3, tenant: "Bob Johnson", property: "Riverside Condos, Apt 3C", subject: "Lease Renewal", date: "2023-07-03", status: "Replied" },
|
||||||
|
{ id: 4, tenant: "Alice Brown", property: "Sunset Apartments, Apt 2C", subject: "Noise Complaint", date: "2023-07-02", status: "Unread" },
|
||||||
|
// Add more mock data as needed
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function MessagesPage() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState("All")
|
||||||
|
const [selectedMessage, setSelectedMessage] = useState<typeof messages[0] | null>(null)
|
||||||
|
|
||||||
|
const filteredMessages = messages.filter(message =>
|
||||||
|
(message.tenant.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
message.property.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
message.subject.toLowerCase().includes(searchTerm.toLowerCase())) &&
|
||||||
|
(selectedStatus === "All" || message.status === selectedStatus)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<CardTitle className="text-2xl">Messages</CardTitle>
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> New Message
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Send New Message</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Compose a new message to send to a tenant or multiple tenants.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="recipients" className="text-right">
|
||||||
|
To
|
||||||
|
</Label>
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="Select recipients" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Tenants</SelectItem>
|
||||||
|
<SelectItem value="sunset">Sunset Apartments</SelectItem>
|
||||||
|
<SelectItem value="oakwood">Oakwood Residences</SelectItem>
|
||||||
|
<SelectItem value="riverside">Riverside Condos</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="subject" className="text-right">
|
||||||
|
Subject
|
||||||
|
</Label>
|
||||||
|
<Input id="subject" className="col-span-3" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="message" className="text-right">
|
||||||
|
Message
|
||||||
|
</Label>
|
||||||
|
<Textarea id="message" className="col-span-3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit">Send Message</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Search messages..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-[300px]"
|
||||||
|
/>
|
||||||
|
<Search className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Filter by status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="All">All Statuses</SelectItem>
|
||||||
|
<SelectItem value="Unread">Unread</SelectItem>
|
||||||
|
<SelectItem value="Read">Read</SelectItem>
|
||||||
|
<SelectItem value="Replied">Replied</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Tenant</TableHead>
|
||||||
|
<TableHead>Property</TableHead>
|
||||||
|
<TableHead>Subject</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredMessages.map((message) => (
|
||||||
|
<TableRow key={message.id}>
|
||||||
|
<TableCell>{message.tenant}</TableCell>
|
||||||
|
<TableCell>{message.property}</TableCell>
|
||||||
|
<TableCell>{message.subject}</TableCell>
|
||||||
|
<TableCell>{message.date}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={
|
||||||
|
message.status === 'Unread' ? 'default' :
|
||||||
|
message.status === 'Read' ? 'secondary' :
|
||||||
|
'outline'
|
||||||
|
}>
|
||||||
|
{message.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setSelectedMessage(message)}>
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{selectedMessage && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<CardTitle className="text-2xl">Message Details</CardTitle>
|
||||||
|
<Button variant="outline" onClick={() => setSelectedMessage(null)}>Close</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src="/placeholder-avatar.jpg" alt={selectedMessage.tenant} />
|
||||||
|
<AvatarFallback>{selectedMessage.tenant[0]}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{selectedMessage.tenant}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{selectedMessage.property}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold">Subject: {selectedMessage.subject}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">Received on {selectedMessage.date}</p>
|
||||||
|
</div>
|
||||||
|
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
|
||||||
|
<Textarea placeholder="Type your reply here..." className="mt-4" />
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<Button variant="outline">Save Draft</Button>
|
||||||
|
<Button>Send Reply</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Message Statistics</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Total Messages
|
||||||
|
</CardTitle>
|
||||||
|
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{messages.length}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Unread Messages
|
||||||
|
</CardTitle>
|
||||||
|
<ArrowUpRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{messages.filter(m => m.status === 'Unread').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Response Rate
|
||||||
|
</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{Math.round((messages.filter(m => m.status === 'Replied').length / messages.length) * 100)}%
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Avg. Response Time
|
||||||
|
</CardTitle>
|
||||||
|
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">4.2 hours</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
174
src/app/admin/page.tsx
Normal file
174
src/app/admin/page.tsx
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "~/components/ui/chart"
|
||||||
|
import { Bar, BarChart, Line, LineChart, ResponsiveContainer, XAxis, YAxis } from "recharts"
|
||||||
|
import { CreditCard, Users, Home, Wrench, AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
// Mock data for charts
|
||||||
|
const rentData = [
|
||||||
|
{ month: "Jan", collected: 95 },
|
||||||
|
{ month: "Feb", collected: 98 },
|
||||||
|
{ month: "Mar", collected: 92 },
|
||||||
|
{ month: "Apr", collected: 97 },
|
||||||
|
{ month: "May", collected: 99 },
|
||||||
|
{ month: "Jun", collected: 94 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const occupancyData = [
|
||||||
|
{ month: "Jan", rate: 92 },
|
||||||
|
{ month: "Feb", rate: 94 },
|
||||||
|
{ month: "Mar", rate: 96 },
|
||||||
|
{ month: "Apr", rate: 95 },
|
||||||
|
{ month: "May", rate: 97 },
|
||||||
|
{ month: "Jun", rate: 98 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function AdminDashboard() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
|
||||||
|
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">$24,560</div>
|
||||||
|
<p className="text-xs text-muted-foreground">+20.1% from last month</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Occupancy Rate</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">98%</div>
|
||||||
|
<p className="text-xs text-muted-foreground">+2% from last month</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Properties</CardTitle>
|
||||||
|
<Home className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">45</div>
|
||||||
|
<p className="text-xs text-muted-foreground">+3 new this month</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Active Work Orders</CardTitle>
|
||||||
|
<Wrench className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">12</div>
|
||||||
|
<p className="text-xs text-muted-foreground">-5 from last week</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Rent Collection Rate</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={{
|
||||||
|
collected: {
|
||||||
|
label: "Collected",
|
||||||
|
color: "hsl(var(--chart-1))",
|
||||||
|
},
|
||||||
|
}} className="h-[200px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={rentData}>
|
||||||
|
<XAxis dataKey="month" />
|
||||||
|
<YAxis />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Bar dataKey="collected" fill="var(--color-collected)" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Occupancy Trend</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={{
|
||||||
|
rate: {
|
||||||
|
label: "Occupancy Rate",
|
||||||
|
color: "hsl(var(--chart-2))",
|
||||||
|
},
|
||||||
|
}} className="h-[200px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={occupancyData}>
|
||||||
|
<XAxis dataKey="month" />
|
||||||
|
<YAxis />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Line type="monotone" dataKey="rate" stroke="var(--color-rate)" strokeWidth={2} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Activities</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-yellow-500 mr-2" />
|
||||||
|
<span className="text-sm">New work order: Leaky faucet at 123 Main St, Apt 4B</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Users className="h-4 w-4 text-green-500 mr-2" />
|
||||||
|
<span className="text-sm">New tenant: John Doe moved into 456 Elm St, Apt 2A</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<CreditCard className="h-4 w-4 text-blue-500 mr-2" />
|
||||||
|
<span className="text-sm">Rent payment received: $1,200 from Jane Smith, 789 Oak Rd, Apt 3C</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Upcoming Lease Renewals</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Sarah Johnson</p>
|
||||||
|
<p className="text-sm text-muted-foreground">123 Main St, Apt 2B</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-medium">Expires in 30 days</p>
|
||||||
|
<Button size="sm">Send Renewal</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Michael Brown</p>
|
||||||
|
<p className="text-sm text-muted-foreground">456 Elm St, Apt 1A</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-medium">Expires in 45 days</p>
|
||||||
|
<Button size="sm">Send Renewal</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
269
src/app/admin/payments/page.tsx
Normal file
269
src/app/admin/payments/page.tsx
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog"
|
||||||
|
import { Label } from "~/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
|
||||||
|
import { Badge } from "~/components/ui/badge"
|
||||||
|
import { Search, Plus, FileText, DollarSign, CreditCard, Calendar } from 'lucide-react'
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "~/components/ui/chart"
|
||||||
|
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from "recharts"
|
||||||
|
|
||||||
|
// Mock data for payments
|
||||||
|
const payments = [
|
||||||
|
{ id: 1, tenant: "John Doe", property: "Sunset Apartments, Apt 4B", amount: 1200, date: "2023-07-01", status: "Paid" },
|
||||||
|
{ id: 2, tenant: "Jane Smith", property: "Oakwood Residences, Apt 2A", amount: 1500, date: "2023-07-02", status: "Paid" },
|
||||||
|
{ id: 3, tenant: "Bob Johnson", property: "Riverside Condos, Apt 3C", amount: 1800, date: "2023-07-05", status: "Pending" },
|
||||||
|
{ id: 4, tenant: "Alice Brown", property: "Sunset Apartments, Apt 2C", amount: 1100, date: "2023-07-03", status: "Late" },
|
||||||
|
// Add more mock data as needed
|
||||||
|
]
|
||||||
|
|
||||||
|
// Mock data for payment chart
|
||||||
|
const paymentChartData = [
|
||||||
|
{ month: "Jan", amount: 45000 },
|
||||||
|
{ month: "Feb", amount: 42000 },
|
||||||
|
{ month: "Mar", amount: 47000 },
|
||||||
|
{ month: "Apr", amount: 44000 },
|
||||||
|
{ month: "May", amount: 46000 },
|
||||||
|
{ month: "Jun", amount: 48000 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function PaymentsPage() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState("All")
|
||||||
|
|
||||||
|
const filteredPayments = payments.filter(payment =>
|
||||||
|
(payment.tenant.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
payment.property.toLowerCase().includes(searchTerm.toLowerCase())) &&
|
||||||
|
(selectedStatus === "All" || payment.status === selectedStatus)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<CardTitle className="text-2xl">Payments</CardTitle>
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Record Payment
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Record New Payment</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter the details of the new payment. Click save when you're done.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="tenant" className="text-right">
|
||||||
|
Tenant
|
||||||
|
</Label>
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="Select tenant" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="john-doe">John Doe</SelectItem>
|
||||||
|
<SelectItem value="jane-smith">Jane Smith</SelectItem>
|
||||||
|
<SelectItem value="bob-johnson">Bob Johnson</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="amount" className="text-right">
|
||||||
|
Amount
|
||||||
|
</Label>
|
||||||
|
<Input id="amount" type="number" className="col-span-3" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="date" className="text-right">
|
||||||
|
Date
|
||||||
|
</Label>
|
||||||
|
<Input id="date" type="date" className="col-span-3" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="method" className="text-right">
|
||||||
|
Payment Method
|
||||||
|
</Label>
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="Select method" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="credit-card">Credit Card</SelectItem>
|
||||||
|
<SelectItem value="bank-transfer">Bank Transfer</SelectItem>
|
||||||
|
<SelectItem value="cash">Cash</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit">Save Payment</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Search payments..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-[300px]"
|
||||||
|
/>
|
||||||
|
<Search className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Filter by status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="All">All Statuses</SelectItem>
|
||||||
|
<SelectItem value="Paid">Paid</SelectItem>
|
||||||
|
<SelectItem value="Pending">Pending</SelectItem>
|
||||||
|
<SelectItem value="Late">Late</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Tenant</TableHead>
|
||||||
|
<TableHead>Property</TableHead>
|
||||||
|
<TableHead>Amount</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredPayments.map((payment) => (
|
||||||
|
<TableRow key={payment.id}>
|
||||||
|
<TableCell>{payment.tenant}</TableCell>
|
||||||
|
<TableCell>{payment.property}</TableCell>
|
||||||
|
<TableCell>${payment.amount}</TableCell>
|
||||||
|
<TableCell>{payment.date}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={
|
||||||
|
payment.status === 'Paid' ? 'default' :
|
||||||
|
payment.status === 'Pending' ? 'secondary' :
|
||||||
|
'destructive'
|
||||||
|
}>
|
||||||
|
{payment.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Payment Statistics</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs defaultValue="overview">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value="trends">Trends</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="overview">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Total Collected
|
||||||
|
</CardTitle>
|
||||||
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">$45,600</div>
|
||||||
|
<p className="text-xs text-muted-foreground">+20.1% from last month</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Pending Payments
|
||||||
|
</CardTitle>
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">$3,800</div>
|
||||||
|
<p className="text-xs text-muted-foreground">5 payments pending</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Late Payments
|
||||||
|
</CardTitle>
|
||||||
|
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">$1,200</div>
|
||||||
|
<p className="text-xs text-muted-foreground">2 payments overdue</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Collection Rate
|
||||||
|
</CardTitle>
|
||||||
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">98%</div>
|
||||||
|
<p className="text-xs text-muted-foreground">+2% from last month</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="trends">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Monthly Payment Trends</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={{
|
||||||
|
amount: {
|
||||||
|
label: "Amount",
|
||||||
|
color: "hsl(var(--chart-1))",
|
||||||
|
},
|
||||||
|
}} className="h-[300px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={paymentChartData}>
|
||||||
|
<XAxis dataKey="month" />
|
||||||
|
<YAxis />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Bar dataKey="amount" fill="var(--color-amount)" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
218
src/app/admin/properties/page.tsx
Normal file
218
src/app/admin/properties/page.tsx
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog"
|
||||||
|
import { Label } from "~/components/ui/label"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
|
||||||
|
import { Badge } from "~/components/ui/badge"
|
||||||
|
import { Search, Plus, Edit, Trash2, Home, DollarSign, Users } from 'lucide-react'
|
||||||
|
|
||||||
|
// Mock data for properties
|
||||||
|
const properties = [
|
||||||
|
{ id: 1, name: "Sunset Apartments", address: "123 Main St", units: 20, occupancy: 18, rentRange: "$1000 - $1500" },
|
||||||
|
{ id: 2, name: "Oakwood Residences", address: "456 Elm St", units: 15, occupancy: 14, rentRange: "$1200 - $1800" },
|
||||||
|
{ id: 3, name: "Riverside Condos", address: "789 Oak Rd", units: 30, occupancy: 28, rentRange: "$1500 - $2200" },
|
||||||
|
// Add more mock data as needed
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function PropertiesPage() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
|
|
||||||
|
const filteredProperties = properties.filter(property =>
|
||||||
|
property.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
property.address.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<CardTitle className="text-2xl">Properties</CardTitle>
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Add Property
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add New Property</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter the details of the new property. Click save when you're done.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="name" className="text-right">
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Input id="name" className="col-span-3" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="address" className="text-right">
|
||||||
|
Address
|
||||||
|
</Label>
|
||||||
|
<Input id="address" className="col-span-3" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="units" className="text-right">
|
||||||
|
Total Units
|
||||||
|
</Label>
|
||||||
|
<Input id="units" type="number" className="col-span-3" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="rentMin" className="text-right">
|
||||||
|
Min Rent
|
||||||
|
</Label>
|
||||||
|
<Input id="rentMin" type="number" className="col-span-3" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="rentMax" className="text-right">
|
||||||
|
Max Rent
|
||||||
|
</Label>
|
||||||
|
<Input id="rentMax" type="number" className="col-span-3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit">Save Property</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Search properties..."placeholder="Search properties..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-[300px]"
|
||||||
|
/>
|
||||||
|
<Search className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Address</TableHead>
|
||||||
|
<TableHead>Units</TableHead>
|
||||||
|
<TableHead>Occupancy</TableHead>
|
||||||
|
<TableHead>Rent Range</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredProperties.map((property) => (
|
||||||
|
<TableRow key={property.id}>
|
||||||
|
<TableCell>{property.name}</TableCell>
|
||||||
|
<TableCell>{property.address}</TableCell>
|
||||||
|
<TableCell>{property.units}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={property.occupancy === property.units ? 'default' : 'secondary'}>
|
||||||
|
{property.occupancy}/{property.units}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{property.rentRange}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Property Statistics</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs defaultValue="overview">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value="financial">Financial</TabsTrigger>
|
||||||
|
<TabsTrigger value="maintenance">Maintenance</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="overview">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Total Properties
|
||||||
|
</CardTitle>
|
||||||
|
<Home className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{properties.length}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Total Units
|
||||||
|
</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{properties.reduce((sum, p) => sum + p.units, 0)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Occupancy Rate
|
||||||
|
</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{Math.round(
|
||||||
|
(properties.reduce((sum, p) => sum + p.occupancy, 0) /
|
||||||
|
properties.reduce((sum, p) => sum + p.units, 0)) * 100
|
||||||
|
)}%
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Avg. Rent
|
||||||
|
</CardTitle>
|
||||||
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">$1,450</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="financial">
|
||||||
|
<p>Detailed financial information and statistics will be displayed here.</p>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="maintenance">
|
||||||
|
<p>Property maintenance history and upcoming tasks will be displayed here.</p>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
236
src/app/admin/tenants/page.tsx
Normal file
236
src/app/admin/tenants/page.tsx
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog"
|
||||||
|
import { Label } from "~/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
|
||||||
|
import { Badge } from "~/components/ui/badge"
|
||||||
|
import { Search, Plus, Edit, Trash2, Mail, Phone } from 'lucide-react'
|
||||||
|
|
||||||
|
// Mock data for tenants
|
||||||
|
const tenants = [
|
||||||
|
{ id: 1, name: "John Doe", email: "john@example.com", phone: "123-456-7890", property: "123 Main St, Apt 4B", leaseEnd: "2023-12-31", status: "Active" },
|
||||||
|
{ id: 2, name: "Jane Smith", email: "jane@example.com", phone: "098-765-4321", property: "456 Elm St, Apt 2A", leaseEnd: "2024-03-15", status: "Active" },
|
||||||
|
{ id: 3, name: "Bob Johnson", email: "bob@example.com", phone: "555-123-4567", property: "789 Oak Rd, Apt 3C", leaseEnd: "2023-09-30", status: "Notice Given" },
|
||||||
|
// Add more mock data as needed
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function TenantsPage() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState("All")
|
||||||
|
|
||||||
|
const filteredTenants = tenants.filter(tenant =>
|
||||||
|
(tenant.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
tenant.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
tenant.property.toLowerCase().includes(searchTerm.toLowerCase())) &&
|
||||||
|
(selectedStatus === "All" || tenant.status === selectedStatus)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<CardTitle className="text-2xl">Tenants</CardTitle>
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Add Tenant
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add New Tenant</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter the details of the new tenant. Click save when you're done.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="name" className="text-right">
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Input id="name" className="col-span-3" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="email" className="text-right">
|
||||||
|
Email
|
||||||
|
</Label>
|
||||||
|
<Input id="email" className="col-span-3" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="phone" className="text-right">
|
||||||
|
Phone
|
||||||
|
</Label>
|
||||||
|
<Input id="phone" className="col-span-3" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="property" className="text-right">
|
||||||
|
Property
|
||||||
|
</Label>
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="Select property" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="123 Main St, Apt 4B">123 Main St, Apt 4B</SelectItem>
|
||||||
|
<SelectItem value="456 Elm St, Apt 2A">456 Elm St, Apt 2A</SelectItem>
|
||||||
|
<SelectItem value="789 Oak Rd, Apt 3C">789 Oak Rd, Apt 3C</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="leaseEnd" className="text-right">
|
||||||
|
Lease End Date
|
||||||
|
</Label>
|
||||||
|
<Input id="leaseEnd" type="date" className="col-span-3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit">Save Tenant</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Search tenants..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-[300px]"
|
||||||
|
/>
|
||||||
|
<Search className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Filter by status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="All">All Statuses</SelectItem>
|
||||||
|
<SelectItem value="Active">Active</SelectItem>
|
||||||
|
<SelectItem value="Notice Given">Notice Given</SelectItem>
|
||||||
|
<SelectItem value="Inactive">Inactive</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Property</TableHead>
|
||||||
|
<TableHead>Lease End</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredTenants.map((tenant) => (
|
||||||
|
<TableRow key={tenant.id}>
|
||||||
|
<TableCell>{tenant.name}</TableCell>
|
||||||
|
<TableCell>{tenant.property}</TableCell>
|
||||||
|
<TableCell>{tenant.leaseEnd}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={tenant.status === 'Active' ? 'default' : 'secondary'}>
|
||||||
|
{tenant.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Phone className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Tenant Statistics</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs defaultValue="overview">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value="leases">Leases</TabsTrigger>
|
||||||
|
<TabsTrigger value="payments">Payments</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="overview">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Total Tenants
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{tenants.length}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Active Leases
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{tenants.filter(t => t.status === 'Active').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Expiring Soon
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">2</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Vacant Units
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">3</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="leases">
|
||||||
|
<p>Detailed lease information and statistics will be displayed here.</p>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="payments">
|
||||||
|
<p>Tenant payment history and statistics will be displayed here.</p>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
298
src/app/admin/workorders/page.tsx
Normal file
298
src/app/admin/workorders/page.tsx
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog"
|
||||||
|
import { Label } from "~/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
|
||||||
|
import { Badge } from "~/components/ui/badge"
|
||||||
|
import { Textarea } from "~/components/ui/textarea"
|
||||||
|
import { Search, Plus, Wrench, Clock, CheckCircle, AlertTriangle } from 'lucide-react'
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "~/components/ui/chart"
|
||||||
|
import { Pie, PieChart, ResponsiveContainer, Cell } from "recharts"
|
||||||
|
|
||||||
|
// Mock data for work orders
|
||||||
|
const workOrders = [
|
||||||
|
{ id: 1, property: "Sunset Apartments, Apt 4B", issue: "Leaky faucet", tenant: "John Doe", date: "2023-07-01", status: "Open" },
|
||||||
|
{ id: 2, property: "Oakwood Residences, Apt 2A", issue: "Broken AC", tenant: "Jane Smith", date: "2023-07-02", status: "In Progress" },
|
||||||
|
{ id: 3, property: "Riverside Condos, Apt 3C", issue: "Clogged drain", tenant: "Bob Johnson", date: "2023-07-03", status: "Completed" },
|
||||||
|
{ id: 4, property: "Sunset Apartments, Apt 2C", issue: "Electrical issue", tenant: "Alice Brown", date: "2023-07-04", status: "Open" },
|
||||||
|
// Add more mock data as needed
|
||||||
|
]
|
||||||
|
|
||||||
|
// Mock data for work order status chart
|
||||||
|
const statusChartData = [
|
||||||
|
{ name: "Open", value: 5 },
|
||||||
|
{ name: "In Progress", value: 3 },
|
||||||
|
{ name: "Completed", value: 8 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const COLORS = ['#0088FE', '#00C49F', '#FFBB28']
|
||||||
|
|
||||||
|
export default function WorkOrdersPage() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState("All")
|
||||||
|
|
||||||
|
const filteredWorkOrders = workOrders.filter(order =>
|
||||||
|
(order.property.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
order.issue.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
order.tenant.toLowerCase().includes(searchTerm.toLowerCase())) &&
|
||||||
|
(selectedStatus === "All" || order.status === selectedStatus)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<CardTitle className="text-2xl">Work Orders</CardTitle>
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Create Work Order
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Work Order</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter the details of the new work order. Click save when you're done.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="property" className="text-right">
|
||||||
|
Property
|
||||||
|
</Label>
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="Select property" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sunset-apartments">Sunset Apartments</SelectItem>
|
||||||
|
<SelectItem value="oakwood-residences">Oakwood Residences</SelectItem>
|
||||||
|
<SelectItem value="riverside-condos">Riverside Condos</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="tenant" className="text-right">
|
||||||
|
Tenant
|
||||||
|
</Label>
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="Select tenant" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="john-doe">John Doe</SelectItem>
|
||||||
|
<SelectItem value="jane-smith">Jane Smith</SelectItem>
|
||||||
|
<SelectItem value="bob-johnson">Bob Johnson</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="issue" className="text-right">
|
||||||
|
Issue
|
||||||
|
</Label>
|
||||||
|
<Input id="issue" className="col-span-3" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="description" className="text-right">
|
||||||
|
Description
|
||||||
|
</Label>
|
||||||
|
<Textarea id="description" className="col-span-3" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="priority" className="text-right">
|
||||||
|
Priority
|
||||||
|
</Label>
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="Select priority" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="low">Low</SelectItem>
|
||||||
|
<SelectItem value="medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="high">High</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit">Create Work Order</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Search work orders..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-[300px]"
|
||||||
|
/>
|
||||||
|
<Search className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Filter by status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="All">All Statuses</SelectItem>
|
||||||
|
<SelectItem value="Open">Open</SelectItem>
|
||||||
|
<SelectItem value="In Progress">In Progress</SelectItem>
|
||||||
|
<SelectItem value="Completed">Completed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Property</TableHead>
|
||||||
|
<TableHead>Issue</TableHead>
|
||||||
|
<TableHead>Tenant</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredWorkOrders.map((order) => (
|
||||||
|
<TableRow key={order.id}>
|
||||||
|
<TableCell>{order.property}</TableCell>
|
||||||
|
<TableCell>{order.issue}</TableCell>
|
||||||
|
<TableCell>{order.tenant}</TableCell>
|
||||||
|
<TableCell>{order.date}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={
|
||||||
|
order.status === 'Open' ? 'default' :
|
||||||
|
order.status === 'In Progress' ? 'secondary' :
|
||||||
|
'success'
|
||||||
|
}>
|
||||||
|
{order.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Wrench className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Work Order Statistics</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs defaultValue="overview">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value="trends">Trends</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="overview">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Total Work Orders
|
||||||
|
</CardTitle>
|
||||||
|
<Wrench className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{workOrders.length}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Open Work Orders
|
||||||
|
</CardTitle>
|
||||||
|
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{workOrders.filter(order => order.status === 'Open').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
In Progress
|
||||||
|
</CardTitle>
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{workOrders.filter(order => order.status === 'In Progress').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Completed
|
||||||
|
</CardTitle>
|
||||||
|
<CheckCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{workOrders.filter(order => order.status === 'Completed').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="trends">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Work Order Status Distribution</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={{
|
||||||
|
status: {
|
||||||
|
label: "Status",
|
||||||
|
color: "hsl(var(--chart-1))",
|
||||||
|
},
|
||||||
|
}} className="h-[300px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={statusChartData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
outerRadius={80}
|
||||||
|
fill="#8884d8"
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{statusChartData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
0
src/app/api/auth/[...nextauth]/route.ts
Normal file → Executable file
0
src/app/api/auth/[...nextauth]/route.ts
Normal file → Executable file
27
src/app/api/users/set_users_name_by_email/route.ts
Executable file
27
src/app/api/users/set_users_name_by_email/route.ts
Executable file
@ -0,0 +1,27 @@
|
|||||||
|
"use server"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import type { NextRequest } from "next/server"
|
||||||
|
import { set_users_name_by_email } from "~/server/functions"
|
||||||
|
import { auth } from "~/auth"
|
||||||
|
|
||||||
|
type updateNameData = {
|
||||||
|
users_name: string;
|
||||||
|
users_email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST = async (req: NextRequest) => {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) return NextResponse.json(
|
||||||
|
{ error: "Not authenticated" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
const { users_name, users_email } = await req.json() as updateNameData;
|
||||||
|
console.log('API received users_name:', users_name, 'users_id:', users_email); // Log received data
|
||||||
|
try {
|
||||||
|
await set_users_name_by_email(users_name, users_email);
|
||||||
|
return NextResponse.json({ message: "Username updated successfully", users_name }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in API route:', error);
|
||||||
|
return NextResponse.json({ error: "Error updating username" }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
27
src/app/api/users/set_users_pfp_by_email/route.ts
Executable file
27
src/app/api/users/set_users_pfp_by_email/route.ts
Executable file
@ -0,0 +1,27 @@
|
|||||||
|
"use server"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import type { NextRequest } from "next/server"
|
||||||
|
import { set_users_pfp_by_email } from "~/server/functions"
|
||||||
|
import { auth } from "~/auth"
|
||||||
|
|
||||||
|
type updateNameData = {
|
||||||
|
users_pfp: string;
|
||||||
|
users_email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST = async (req: NextRequest) => {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) return NextResponse.json(
|
||||||
|
{ error: "Not authenticated" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
const { users_pfp, users_email } = await req.json() as updateNameData;
|
||||||
|
console.log('API received users_pfp:', users_pfp, 'users_id:', users_email); // Log received data
|
||||||
|
try {
|
||||||
|
await set_users_pfp_by_email(users_pfp, users_email);
|
||||||
|
return NextResponse.json({ message: "Username updated successfully", users_pfp }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in API route:', error);
|
||||||
|
return NextResponse.json({ error: "Error updating username" }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
83
src/app/contact/page.tsx
Normal file
83
src/app/contact/page.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import ContactForm from '~/components/contact/contact-form'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
|
import { MapPin, Phone, Mail, Clock } from 'lucide-react'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Contact Us | PropertyPro',
|
||||||
|
description: 'Get in touch with PropertyPro for any inquiries about our properties or services.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContactPage() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-8 text-primary">Contact Us</h1>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
|
<div>
|
||||||
|
<ContactForm />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Our Office</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<p className="flex items-center">
|
||||||
|
<MapPin className="mr-2 h-4 w-4 text-primary" />
|
||||||
|
123 Property Street, Cityville, State 12345
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center">
|
||||||
|
<Phone className="mr-2 h-4 w-4 text-primary" />
|
||||||
|
(123) 456-7890
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center">
|
||||||
|
<Mail className="mr-2 h-4 w-4 text-primary" />
|
||||||
|
info@propertypro.com
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center">
|
||||||
|
<Clock className="mr-2 h-4 w-4 text-primary" />
|
||||||
|
Mon-Fri: 9am-5pm, Sat: 10am-3pm, Sun: Closed
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>FAQ</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-1">How do I schedule a property viewing?</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">You can schedule a viewing by contacting our office or using the form on this page.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-1">What documents do I need to apply for a rental?</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Typically, you'll need proof of income, identification, and references. Specific requirements may vary.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-1">How do I report maintenance issues?</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Tenants can report maintenance issues through their online account or by contacting our office directly.</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="aspect-w-16 aspect-h-9">
|
||||||
|
<iframe
|
||||||
|
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3022.1422937950147!2d-73.98731968459391!3d40.74844797932681!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x89c259a9b3117469%3A0xd134e199a405a163!2sEmpire%20State%20Building!5e0!3m2!1sen!2sus!4v1616562308246!5m2!1sen!2sus"
|
||||||
|
width="600"
|
||||||
|
height="450"
|
||||||
|
style={{border:0}}
|
||||||
|
allowFullScreen={true}
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-full rounded-lg"
|
||||||
|
title="PropertyPro Office Location"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
128
src/app/layout.tsx
Normal file → Executable file
128
src/app/layout.tsx
Normal file → Executable file
@ -1,9 +1,14 @@
|
|||||||
import "~/styles/globals.css";
|
import "~/styles/globals.css";
|
||||||
import { Inter as FontSans } from "next/font/google";
|
import { Inter as FontSans } from "next/font/google";
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils"
|
||||||
|
import { auth } from "~/auth"
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
import Theme_Provider from "~/components/theme/theme_provider"
|
import Theme_Provider from "~/components/theme/theme_provider"
|
||||||
import { type Metadata } from "next";
|
import { type Metadata } from "next";
|
||||||
|
import Theme_Toggle from '~/components/theme/theme_toggle'
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import Link from 'next/link'
|
||||||
|
import Avatar_Popover from "~/components/auth/AvatarPopover";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Tenant Portal",
|
title: "Tenant Portal",
|
||||||
@ -16,9 +21,11 @@ const fontSans = FontSans({
|
|||||||
variable: "--font-sans",
|
variable: "--font-sans",
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body
|
||||||
@ -33,10 +40,129 @@ export default function RootLayout({
|
|||||||
disableTransitionOnChange={true}
|
disableTransitionOnChange={true}
|
||||||
>
|
>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
|
<div className="flex flex-col min-h-screen">
|
||||||
|
<header className="bg-background shadow-sm">
|
||||||
|
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
|
||||||
|
<Link href="/" className="md:text-2xl font-bold text-primary">Magnolia Coast Properties</Link>
|
||||||
|
<nav className="hidden md:flex space-x-2 lg:space-x-4 text-sm lg:text-lg">
|
||||||
|
<Link href="/properties" className="text-gray-600 hover:text-primary">Properties</Link>
|
||||||
|
<Link href="/services" className="text-gray-600 hover:text-primary">Services</Link>
|
||||||
|
<Link href="/about" className="text-gray-600 hover:text-primary">About</Link>
|
||||||
|
<Link href="/contact" className="text-gray-600 hover:text-primary">Contact</Link>
|
||||||
|
</nav>
|
||||||
|
<div className="flex space-x-2 md:space-x-4">
|
||||||
|
<Theme_Toggle/>
|
||||||
|
<Link href="/login">
|
||||||
|
<Button variant="outline">Login</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
{children}
|
{children}
|
||||||
|
<footer className="bg-background text-primary py-8">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold mb-4">Magnolia Coast Property Management</h3>
|
||||||
|
<p>Your trusted partner in property management</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-lg font-semibold mb-4">Quick Links</h4>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li><Link href="/properties" className="hover:text-primary-foreground">Properties</Link></li>
|
||||||
|
<li><Link href="/services" className="hover:text-primary-foreground">Services</Link></li>
|
||||||
|
<li><Link href="/about" className="hover:text-primary-foreground">About Us</Link></li>
|
||||||
|
<li><Link href="/contact" className="hover:text-primary-foreground">Contact</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-lg font-semibold mb-4">Contact Us</h4>
|
||||||
|
<p>123 Property Street</p>
|
||||||
|
<p>City, State 12345</p>
|
||||||
|
<p>Phone: (123) 456-7890</p>
|
||||||
|
<p>Email: info@propertypro.com</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<p>© 2024 Magnolia Coast Property Management LLC. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</Theme_Provider>
|
</Theme_Provider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body
|
||||||
|
className={cn(
|
||||||
|
"min-h-screen bg-background font-sans antialiased",
|
||||||
|
fontSans.variable)}
|
||||||
|
>
|
||||||
|
<Theme_Provider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem={true}
|
||||||
|
disableTransitionOnChange={true}
|
||||||
|
>
|
||||||
|
<SessionProvider>
|
||||||
|
|
||||||
|
<div className="flex flex-col min-h-screen">
|
||||||
|
<header className="bg-background shadow-sm">
|
||||||
|
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
|
||||||
|
<Link href="/" className="md:text-2xl font-bold text-primary">Magnolia Coast Properties</Link>
|
||||||
|
<nav className="hidden md:flex space-x-2 lg:space-x-4 text-sm lg:text-lg">
|
||||||
|
<Link href="/account" className="text-gray-600 hover:text-primary">My Account</Link>
|
||||||
|
<Link href="/properties" className="text-gray-600 hover:text-primary">Properties</Link>
|
||||||
|
<Link href="/services" className="text-gray-600 hover:text-primary">Services</Link>
|
||||||
|
<Link href="/about" className="text-gray-600 hover:text-primary">About</Link>
|
||||||
|
<Link href="/contact" className="text-gray-600 hover:text-primary">Contact</Link>
|
||||||
|
</nav>
|
||||||
|
<div className="flex space-x-2 md:space-x-4">
|
||||||
|
<Theme_Toggle/>
|
||||||
|
<Avatar_Popover/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
<footer className="bg-background text-primary py-8">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold mb-4">Magnolia Coast Property Management</h3>
|
||||||
|
<p>Your trusted partner in property management</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-lg font-semibold mb-4">Quick Links</h4>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li><Link href="/account" className="hover:text-primary-foreground">My Account</Link></li>
|
||||||
|
<li><Link href="/properties" className="hover:text-primary-foreground">Properties</Link></li>
|
||||||
|
<li><Link href="/services" className="hover:text-primary-foreground">Services</Link></li>
|
||||||
|
<li><Link href="/about" className="hover:text-primary-foreground">About Us</Link></li>
|
||||||
|
<li><Link href="/contact" className="hover:text-primary-foreground">Contact</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-lg font-semibold mb-4">Contact Us</h4>
|
||||||
|
<p>123 Property Street</p>
|
||||||
|
<p>City, State 12345</p>
|
||||||
|
<p>Phone: (123) 456-7890</p>
|
||||||
|
<p>Email: info@propertypro.com</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<p>© 2024 Magnolia Coast Property Management LLC. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</SessionProvider>
|
||||||
|
</Theme_Provider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
127
src/app/login/page.tsx
Normal file
127
src/app/login/page.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
|
||||||
|
import { Label } from "~/components/ui/label"
|
||||||
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
|
import Sign_In from "~/components/auth/client/SignInAppleButton"
|
||||||
|
|
||||||
|
export default function AuthPage() {
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
|
||||||
|
const togglePasswordVisibility = () => setShowPassword(!showPassword)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl font-bold text-center text-primary">
|
||||||
|
Welcome to Magnolia Coast Property Management
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-center text-muted-foreground">
|
||||||
|
Login or create an account to get started
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs defaultValue="login" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="login">Login</TabsTrigger>
|
||||||
|
<TabsTrigger value="register">Register</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="login">
|
||||||
|
<form>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input id="email" placeholder="Enter your email" type="email" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||||
|
onClick={togglePasswordVisibility}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-center">
|
||||||
|
<Button className="w-full mt-6 mb-4 text-md" type="submit">Login</Button>
|
||||||
|
<Sign_In/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="register">
|
||||||
|
<form>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="register-name">Full Name</Label>
|
||||||
|
<Input id="register-name" placeholder="Enter your full name" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="register-email">Email</Label>
|
||||||
|
<Input id="register-email" placeholder="Enter your email" type="email" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="register-password">Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="register-password"
|
||||||
|
placeholder="Create a password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||||
|
onClick={togglePasswordVisibility}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-center">
|
||||||
|
<Button className="w-full mt-6 mb-4 text-md" type="submit">Register</Button>
|
||||||
|
<Sign_In/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex justify-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
By continuing, you agree to our{' '}
|
||||||
|
<a href="#" className="underline text-primary">Terms of Service</a> and{' '}
|
||||||
|
<a href="#" className="underline text-primary">Privacy Policy</a>
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
112
src/app/page.tsx
Normal file → Executable file
112
src/app/page.tsx
Normal file → Executable file
@ -1,36 +1,90 @@
|
|||||||
"use server"
|
import Link from 'next/link'
|
||||||
import Theme_Toggle from "~/components/theme/theme_toggle"
|
import { Button } from "~/components/ui/button"
|
||||||
import { auth } from "~/auth"
|
import { Input } from "~/components/ui/input"
|
||||||
import Sign_In_Apple_Button from "~/components/auth/server/SignInAppleButton"
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
import Sign_Out_Button from "~/components/auth/server/SignOutButton"
|
import { Search, Home, Key, Wrench, DollarSign } from 'lucide-react'
|
||||||
import Title from "~/components/home/Title"
|
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const session = await auth();
|
|
||||||
if (!session) {
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen">
|
<main className="flex-grow">
|
||||||
<div className="w-full justify-end items-end p-3 flex flex-col">
|
<section className="bg-gradient-to-r from-background to-primary-foreground text-primary py-20">
|
||||||
<Theme_Toggle />
|
<div className="container mx-auto px-4 text-center">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold mb-4">Find Your Perfect Property</h1>
|
||||||
|
<p className="text-xl mb-8">Discover a wide range of properties for sale and rent</p>
|
||||||
|
<div className="max-w-2xl mx-auto flex">
|
||||||
|
<Input type="text" placeholder="Search properties..." className="flex-grow" />
|
||||||
|
<Button className="ml-2">
|
||||||
|
<Search className="mr-2 h-4 w-4" /> Search
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-col justify-center items-center">
|
|
||||||
<Title />
|
|
||||||
<Sign_In_Apple_Button />
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-16">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<h2 className="text-3xl font-bold mb-8 text-center">Featured Properties</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Beautiful Property {i}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<img src={`/placeholder.svg?height=200&width=300`} alt={`Property ${i}`} className="w-full h-48 object-cover mb-4 rounded" />
|
||||||
|
<p className="text-gray-600">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Link href={`/properties/${i}`} passHref>
|
||||||
|
<Button className="w-full">View Details</Button>
|
||||||
|
</Link>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-background py-16">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<h2 className="text-3xl font-bold mb-8 text-center">Our Services</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
|
{[
|
||||||
|
{ icon: Home, title: "Property Management" },
|
||||||
|
{ icon: Key, title: "Rentals" },
|
||||||
|
{ icon: Wrench, title: "Maintenance" },
|
||||||
|
{ icon: DollarSign, title: "Rent Collection" },
|
||||||
|
].map((service, i) => (
|
||||||
|
<Card key={i} className="text-center">
|
||||||
|
<CardHeader>
|
||||||
|
<service.icon className="mx-auto h-12 w-12 text-primary" />
|
||||||
|
<CardTitle>{service.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-gray-600">Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
);
|
)
|
||||||
} else {
|
|
||||||
const email = session?.user?.email;
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen">
|
|
||||||
<div className="w-full justify-end items-end p-3 flex flex-col">
|
|
||||||
<Theme_Toggle />
|
|
||||||
<div className="w-full flex flex-col justify-center items-center">
|
|
||||||
<h1>Welcome, {email}</h1>
|
|
||||||
<Sign_Out_Button />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
//"use server"
|
||||||
|
//import { auth } from "~/auth"
|
||||||
|
//import Breadcrumb_Home from "~/components/home/breadcrumb/BreadcrumbHome"
|
||||||
|
|
||||||
|
//export default async function HomePage() {
|
||||||
|
//const session = await auth()
|
||||||
|
//if (!session?.user) return <></>
|
||||||
|
//return (
|
||||||
|
//<div className="w-2/3 flex flex-col p-6">
|
||||||
|
//<div className="flex flex-row">
|
||||||
|
//<div className="">
|
||||||
|
//<Breadcrumb_Home />
|
||||||
|
//</div>
|
||||||
|
//</div>
|
||||||
|
//</div>
|
||||||
|
//);
|
||||||
|
//}
|
||||||
|
86
src/app/properties/[id]/page.tsx
Normal file
86
src/app/properties/[id]/page.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
|
import { Home, Bed, Bath, Square, MapPin, Calendar, DollarSign } from 'lucide-react'
|
||||||
|
|
||||||
|
// This would typically come from your database
|
||||||
|
const property = {
|
||||||
|
id: 1,
|
||||||
|
title: "Luxurious Family Home",
|
||||||
|
type: "House",
|
||||||
|
bedrooms: 4,
|
||||||
|
bathrooms: 3,
|
||||||
|
area: 2500,
|
||||||
|
price: 750000,
|
||||||
|
address: "123 Main St, Anytown, USA",
|
||||||
|
description: "This beautiful family home features spacious living areas, a modern kitchen, and a large backyard perfect for entertaining. Located in a quiet neighborhood with easy access to schools and shopping.",
|
||||||
|
amenities: ["Central Air", "Garage", "Fireplace", "Hardwood Floors", "Swimming Pool"],
|
||||||
|
yearBuilt: 2015,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PropertyPage({ params }: { params: { id: string } }) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<Link href="/properties" className="text-primary hover:underline mb-4 inline-block">
|
||||||
|
← Back to Properties
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
|
<div>
|
||||||
|
<img src="/placeholder.svg?height=400&width=600" alt={property.title} className="w-full h-[400px] object-cover rounded-lg" />
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||||
|
<img src="/placeholder.svg?height=150&width=250" alt="Additional view 1" className="w-full h-[150px] object-cover rounded" />
|
||||||
|
<img src="/placeholder.svg?height=150&width=250" alt="Additional view 2" className="w-full h-[150px] object-cover rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold mb-4 text-primary">{property.title}</h1>
|
||||||
|
<p className="text-xl font-semibold mb-4 text-primary">${property.price.toLocaleString()}</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||||
|
<div className="flex items-center text-muted-foreground">
|
||||||
|
<Home className="mr-2" /> {property.type}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-muted-foreground">
|
||||||
|
<Bed className="mr-2" /> {property.bedrooms} Bedrooms
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-muted-foreground">
|
||||||
|
<Bath className="mr-2" /> {property.bathrooms} Bathrooms
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-muted-foreground">
|
||||||
|
<Square className="mr-2" /> {property.area} sqft
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-muted-foreground">
|
||||||
|
<MapPin className="mr-2" /> {property.address}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-muted-foreground">
|
||||||
|
<Calendar className="mr-2" /> Built in {property.yearBuilt}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-6 text-muted-foreground">{property.description}</p>
|
||||||
|
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Amenities</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="grid grid-cols-2 gap-2">
|
||||||
|
{property.amenities.map((amenity, index) => (
|
||||||
|
<li key={index} className="flex items-center text-muted-foreground">
|
||||||
|
<DollarSign className="mr-2 h-4 w-4" /> {amenity}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button className="w-full">Schedule a Viewing</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
116
src/app/properties/page.tsx
Normal file
116
src/app/properties/page.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"
|
||||||
|
import { Slider } from "~/components/ui/slider"
|
||||||
|
import { Search, Home, Bed, Bath, Square } from 'lucide-react'
|
||||||
|
|
||||||
|
// Mock data for properties
|
||||||
|
const properties = Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
title: `Property ${i + 1}`,
|
||||||
|
type: i % 3 === 0 ? 'House' : i % 3 === 1 ? 'Apartment' : 'Condo',
|
||||||
|
bedrooms: Math.floor(Math.random() * 5) + 1,
|
||||||
|
bathrooms: Math.floor(Math.random() * 3) + 1,
|
||||||
|
area: Math.floor(Math.random() * 1000) + 500,
|
||||||
|
price: Math.floor(Math.random() * 500000) + 100000,
|
||||||
|
}))
|
||||||
|
|
||||||
|
export default function PropertiesPage() {
|
||||||
|
const [sortBy, setSortBy] = useState('price')
|
||||||
|
const [filterType, setFilterType] = useState('All')
|
||||||
|
const [priceRange, setPriceRange] = useState([0, 1000000])
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
|
||||||
|
const filteredAndSortedProperties = properties
|
||||||
|
.filter(property =>
|
||||||
|
(filterType === 'All' || property.type === filterType) &&
|
||||||
|
property.price >= priceRange[0] && property.price <= priceRange[1] &&
|
||||||
|
property.title.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (sortBy === 'price') return a.price - b.price
|
||||||
|
if (sortBy === 'bedrooms') return b.bedrooms - a.bedrooms
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-8 text-primary">Available Properties</h1>
|
||||||
|
|
||||||
|
<div className="mb-8 flex flex-wrap gap-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Search properties..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
<Select onValueChange={setSortBy} defaultValue={sortBy}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Sort by" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="price">Price</SelectItem>
|
||||||
|
<SelectItem value="bedrooms">Bedrooms</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select onValueChange={setFilterType} defaultValue={filterType}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Property type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="All">All</SelectItem>
|
||||||
|
<SelectItem value="House">House</SelectItem>
|
||||||
|
<SelectItem value="Apartment">Apartment</SelectItem>
|
||||||
|
<SelectItem value="Condo">Condo</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-2 text-primary">Price Range</h2>
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={1000000}
|
||||||
|
step={10000}
|
||||||
|
value={priceRange}
|
||||||
|
onValueChange={setPriceRange}
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 text-sm text-muted-foreground">
|
||||||
|
${priceRange[0].toLocaleString()} - ${priceRange[1].toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{filteredAndSortedProperties.map((property) => (
|
||||||
|
<Card key={property.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{property.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<img src={`/placeholder.svg?height=200&width=300`} alt={property.title} className="w-full h-48 object-cover mb-4 rounded" />
|
||||||
|
<div className="flex justify-between text-muted-foreground">
|
||||||
|
<span><Home className="inline mr-1" /> {property.type}</span>
|
||||||
|
<span><Bed className="inline mr-1" /> {property.bedrooms}</span>
|
||||||
|
<span><Bath className="inline mr-1" /> {property.bathrooms}</span>
|
||||||
|
<span><Square className="inline mr-1" /> {property.area} sqft</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-xl font-bold text-primary">${property.price.toLocaleString()}</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Link href={`/properties/${property.id}`} passHref>
|
||||||
|
<Button className="w-full">View Details</Button>
|
||||||
|
</Link>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
0
src/auth.config.ts
Normal file → Executable file
0
src/auth.config.ts
Normal file → Executable file
0
src/auth.ts
Normal file → Executable file
0
src/auth.ts
Normal file → Executable file
47
src/components/auth/AvatarPopover.tsx
Executable file
47
src/components/auth/AvatarPopover.tsx
Executable file
@ -0,0 +1,47 @@
|
|||||||
|
"use server"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "~/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "~/components/ui/avatar"
|
||||||
|
import { auth } from "~/auth"
|
||||||
|
import Sign_Out_Button from "~/components/auth/server/SignOutButton"
|
||||||
|
|
||||||
|
export default async function Avatar_Popover() {
|
||||||
|
const session = await auth();
|
||||||
|
const pfp = session?.user?.image ?? "";
|
||||||
|
const users_name = session?.user?.name ?? "New User";
|
||||||
|
const initials = users_name.split(" ").map((name) => name[0]).join("");
|
||||||
|
console.log(pfp);
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src={pfp} alt="@shadcn"/>
|
||||||
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuLabel>{users_name}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<div className="w-full flex flex-row
|
||||||
|
justify-center items-center">
|
||||||
|
Settings
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Sign_Out_Button />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
148
src/components/auth/FirstSignInForm.tsx
Executable file
148
src/components/auth/FirstSignInForm.tsx
Executable file
@ -0,0 +1,148 @@
|
|||||||
|
"use client"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "~/components/ui/alert-dialog"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from "~/components/ui/form"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
users_name: z.string().min(2, {
|
||||||
|
message: "Really? Your name is one letter? You expect me to believe that?",
|
||||||
|
}),
|
||||||
|
users_profile_image: z.string().url().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function First_Sign_In_Form({ users_name, users_email }: { users_name: string, users_email: string }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const update_users_name = async (users_name: string, users_email: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/users/set_users_name_by_email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
users_name: users_name,
|
||||||
|
users_email: users_email,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to update user's name");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Could not update user's name", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const update_users_pfp = async (users_pfp: string, users_email: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/users/set_users_pfp_by_email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
users_pfp: users_pfp,
|
||||||
|
users_email: users_email,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to update user's name");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Could not update user's name", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: z.infer<typeof formSchema>) => {
|
||||||
|
if (data.users_profile_image === undefined) {
|
||||||
|
data.users_profile_image = "";
|
||||||
|
}
|
||||||
|
await update_users_name(data.users_name, users_email);
|
||||||
|
await update_users_pfp(data.users_profile_image, users_email);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (users_name === "New User") {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}, [users_name]);
|
||||||
|
|
||||||
|
|
||||||
|
if (users_name === "New User") {
|
||||||
|
return (
|
||||||
|
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Welcome to the Tenant Portal</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Please fill out the form to complete your account setup.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="users_name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Enter your name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="First & Last Name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="users_profile_image"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Enter a URL path to your profile picture</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="https://example.com/profile.jpg" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
</FormField>
|
||||||
|
<div className="w-full flex justify-end">
|
||||||
|
<Button type="submit" variant="secondary"
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
9
src/components/auth/No_Session.tsx
Normal file
9
src/components/auth/No_Session.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
const No_Session = () => {
|
||||||
|
return (
|
||||||
|
<div className="w-2/3 mx-auto text-center pt-10">
|
||||||
|
<h1 className="text-3xl font-semibold pb-4">Unauthorized Access</h1>
|
||||||
|
<p className="text-xl">Please sign in to view this page.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default No_Session;
|
12
src/components/auth/client/SignInAppleButton.tsx
Normal file → Executable file
12
src/components/auth/client/SignInAppleButton.tsx
Normal file → Executable file
@ -4,12 +4,12 @@ import Image from "next/image"
|
|||||||
|
|
||||||
export default function Sign_In() {
|
export default function Sign_In() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row bg-primary py-3 px-10 rounded-xl text-lg font-semibold
|
<Button onClick={() => signIn("apple")} className="flex flex-row bg-primary py-3
|
||||||
mt-10 text-background my-auto">
|
px-10 rounded-md text-md font-semibold text-background">
|
||||||
<Image src="/logos/Apple_logo_black.svg" alt="Apple logo" width={20} height={20}
|
<Image src="/logos/Apple_logo_black.svg" alt="Apple logo" width={16} height={16}
|
||||||
className="mr-4 my-auto"
|
className="mr-4"
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => signIn("apple")}>Sign in with Apple</Button>
|
Sign in with Apple
|
||||||
</div>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
10
src/components/auth/client/SignOutButton.tsx
Normal file → Executable file
10
src/components/auth/client/SignOutButton.tsx
Normal file → Executable file
@ -2,5 +2,13 @@ import { signOut } from "next-auth/react"
|
|||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button"
|
||||||
|
|
||||||
export default function Sign_Out() {
|
export default function Sign_Out() {
|
||||||
return <Button onClick={() => signOut()}>Sign Out</Button>
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={() => signOut()}
|
||||||
|
className="flex flex-row bg-yellow py-3
|
||||||
|
px-10 rounded-md text-md font-semibold text-background"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
0
src/components/auth/server/SignInAppleButton.tsx
Normal file → Executable file
0
src/components/auth/server/SignInAppleButton.tsx
Normal file → Executable file
7
src/components/auth/server/SignOutButton.tsx
Normal file → Executable file
7
src/components/auth/server/SignOutButton.tsx
Normal file → Executable file
@ -1,14 +1,19 @@
|
|||||||
|
import { Button } from "~/components/ui/button"
|
||||||
import { signOut } from "~/auth"
|
import { signOut } from "~/auth"
|
||||||
|
|
||||||
export default function Sign_Out() {
|
export default function Sign_Out() {
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
|
className="w-full"
|
||||||
action={async () => {
|
action={async () => {
|
||||||
"use server"
|
"use server"
|
||||||
await signOut()
|
await signOut()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button type="submit">Sign Out</button>
|
<Button type="submit" className="w-full"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
82
src/components/contact/contact-form.tsx
Normal file
82
src/components/contact/contact-form.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Textarea } from "~/components/ui/textarea"
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card"
|
||||||
|
import { Label } from "~/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"
|
||||||
|
|
||||||
|
export default function ContactForm() {
|
||||||
|
const [formStatus, setFormStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle')
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setFormStatus('submitting')
|
||||||
|
|
||||||
|
// Here you would typically send the form data to your server
|
||||||
|
// For this example, we'll just simulate a submission
|
||||||
|
setTimeout(() => {
|
||||||
|
setFormStatus('success')
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Send us a message</CardTitle>
|
||||||
|
<CardDescription>We'll get back to you as soon as possible.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="grid w-full items-center gap-4">
|
||||||
|
<div className="flex flex-col space-y-1.5">
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input id="name" name="name" placeholder="Your name" required />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col space-y-1.5">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input id="email" name="email" placeholder="Your email" type="email" required />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col space-y-1.5">
|
||||||
|
<Label htmlFor="phone">Phone</Label>
|
||||||
|
<Input id="phone" name="phone" placeholder="Your phone number" type="tel" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col space-y-1.5">
|
||||||
|
<Label htmlFor="subject">Subject</Label>
|
||||||
|
<Select name="subject" required>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a subject" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="general">General Inquiry</SelectItem>
|
||||||
|
<SelectItem value="viewing">Property Viewing</SelectItem>
|
||||||
|
<SelectItem value="application">Rental Application</SelectItem>
|
||||||
|
<SelectItem value="maintenance">Maintenance Request</SelectItem>
|
||||||
|
<SelectItem value="other">Other</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col space-y-1.5">
|
||||||
|
<Label htmlFor="message">Message</Label>
|
||||||
|
<Textarea id="message" name="message" placeholder="Your message" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardFooter className="flex justify-between mt-4 px-0">
|
||||||
|
<Button type="submit" disabled={formStatus === 'submitting'}>
|
||||||
|
{formStatus === 'submitting' ? 'Sending...' : 'Send Message'}
|
||||||
|
</Button>
|
||||||
|
{formStatus === 'success' && (
|
||||||
|
<p className="text-sm text-green-600">Message sent successfully!</p>
|
||||||
|
)}
|
||||||
|
{formStatus === 'error' && (
|
||||||
|
<p className="text-sm text-red-600">There was an error sending your message. Please try again.</p>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
87
src/components/portal/billtracker/BillForm.tsx
Executable file
87
src/components/portal/billtracker/BillForm.tsx
Executable file
@ -0,0 +1,87 @@
|
|||||||
|
"use server"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
billType: z.enum(["Rent", "Power", "Internet", "Gas", "Water", "Phone Bill", "Cable",
|
||||||
|
"Security Deposit", "Other"]),
|
||||||
|
billDescription: z.string().optional(),
|
||||||
|
amount: z.number(),
|
||||||
|
recurrence: z.enum(["No recurrence", "Monthly", "Bi-weekly", "Weekly", "Annually"]),
|
||||||
|
includeUserInSplit: z.boolean().optional(),
|
||||||
|
roommates: z.array(z.object({
|
||||||
|
userID: z.string(),
|
||||||
|
includeInSplit: z.boolean(),
|
||||||
|
})).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createBill(formData: FormData) {
|
||||||
|
const validateFields = schema.safeParse({
|
||||||
|
billType: formData.get("billType"),
|
||||||
|
billDescription: formData.get("billDescription"),
|
||||||
|
amount: formData.get("amount"),
|
||||||
|
recurrence: formData.get("recurrence"),
|
||||||
|
includeUserInSplit: formData.get("includeUserInSplit"),
|
||||||
|
roommates: formData.get("roommates"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validateFields.success) {
|
||||||
|
return {
|
||||||
|
errors: validateFields.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BillForm() {
|
||||||
|
const form = useForm<z.infer<typeof schema>>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
//billType: "Rent",
|
||||||
|
recurrence: "No recurrence",
|
||||||
|
includeUserInSplit: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div className="">
|
||||||
|
<form action={createBill}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="billType">Bill Type</label>
|
||||||
|
<select id="billType" name="billType" required className="">
|
||||||
|
<option value="Rent">Rent</option>
|
||||||
|
<option value="Power">Power</option>
|
||||||
|
<option value="Internet">Internet</option>
|
||||||
|
<option value="Gas">Gas</option>
|
||||||
|
<option value="Water">Water</option>
|
||||||
|
<option value="Phone Bill">Phone Bill</option>
|
||||||
|
<option value="Cable">Cable</option>
|
||||||
|
<option value="Security Deposit">Security Deposit</option>
|
||||||
|
<option value="Other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="billDescription">Bill Description</label>
|
||||||
|
<input type="text" id="billDescription" name="billDescription" className="" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="amount">Amount</label>
|
||||||
|
<input type="number" id="amount" name="amount" required className="" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="recurrence">Recurrence</label>
|
||||||
|
<select id="recurrence" name="recurrence" required className="">
|
||||||
|
<option value="No recurrence">No recurrence</option>
|
||||||
|
<option value="Monthly">Monthly</option>
|
||||||
|
<option value="Bi-weekly">Bi-weekly</option>
|
||||||
|
<option value="Weekly">Weekly</option>
|
||||||
|
<option value="Annually">Annually</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="includeUserInSplit">Include Yourself in Bill Split?</label>
|
||||||
|
<input type="checkbox" id="includeUserInSplit" name="includeUserInSplit" className="" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
65
src/components/portal/billtracker/BillTrackerCalendar.tsx
Executable file
65
src/components/portal/billtracker/BillTrackerCalendar.tsx
Executable file
@ -0,0 +1,65 @@
|
|||||||
|
"use client"
|
||||||
|
import * as React from "react"
|
||||||
|
import { Calendar } from "~/components/ui/BillTrackerCalendar"
|
||||||
|
import CreateBillForm from "~/components/portal/billtracker/CreateBillForm"
|
||||||
|
|
||||||
|
export default function BillTrackerCalendar() {
|
||||||
|
const [selectedDate, setSelectedDate] = React.useState<Date | undefined>(undefined)
|
||||||
|
const [isOpen, setIsOpen] = React.useState(false)
|
||||||
|
const calendarRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
const popoverRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const handleSelect = (date: Date | undefined) => {
|
||||||
|
if (date) {
|
||||||
|
if (selectedDate && date.getTime() === selectedDate.getTime())
|
||||||
|
setIsOpen(!isOpen)
|
||||||
|
else {
|
||||||
|
setSelectedDate(date)
|
||||||
|
setIsOpen(true)
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
calendarRef.current &&
|
||||||
|
popoverRef.current &&
|
||||||
|
!calendarRef.current.contains(event.target as Node) &&
|
||||||
|
!popoverRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
//setIsOpen(false)
|
||||||
|
console.log('Calendar closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="m-auto p-2 relative mt-10" ref={calendarRef}>
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={selectedDate}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
className="rounded-md border m-auto"
|
||||||
|
/>
|
||||||
|
{isOpen && selectedDate && (
|
||||||
|
<div
|
||||||
|
ref={popoverRef}
|
||||||
|
className="absolute top-full left-1/2 transform -translate-x-1/2
|
||||||
|
border rounded-lg shadow-lg px-4 pb-4 w-80"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
< CreateBillForm date={selectedDate} setIsOpen={setIsOpen} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
319
src/components/portal/billtracker/CreateBillDrawer.tsx
Executable file
319
src/components/portal/billtracker/CreateBillDrawer.tsx
Executable file
@ -0,0 +1,319 @@
|
|||||||
|
"use client"
|
||||||
|
import * as React from "react"
|
||||||
|
import { Check, ChevronsUpDown } from "lucide-react"
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
import {
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from "~/components/ui/drawer"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from "~/components/ui/form"
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandList,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
} from "~/components/ui/command"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "~/components/ui/popover"
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox"
|
||||||
|
|
||||||
|
type CreateBillDrawerProps = {
|
||||||
|
date: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ComboOption = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormSchema = z.object({
|
||||||
|
billType: z.enum(["Rent", "Power", "Internet", "Gas", "Water", "Phone Bill", "Cable",
|
||||||
|
"Security Deposit", "Other"]),
|
||||||
|
billDescription: z.string().optional(),
|
||||||
|
amount: z.number(),
|
||||||
|
recurrence: z.enum(["No recurrence", "Monthly", "Bi-weekly", "Weekly", "Annually"]),
|
||||||
|
includeUserInSplit: z.boolean().optional(),
|
||||||
|
roommates: z.array(z.object({
|
||||||
|
userID: z.string(),
|
||||||
|
includeInSplit: z.boolean(),
|
||||||
|
})).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const billTypeCombo: ComboOption[] =
|
||||||
|
FormSchema.shape.billType._def.values.map((billType: string) => ({
|
||||||
|
value: billType,
|
||||||
|
label: billType,
|
||||||
|
}));
|
||||||
|
const recurrenceCombo: ComboOption[] =
|
||||||
|
FormSchema.shape.recurrence._def.values.map((recurrence: string) => ({
|
||||||
|
value: recurrence,
|
||||||
|
label: recurrence,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default function CreateBillDrawer({date}: CreateBillDrawerProps) {
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof FormSchema>>({
|
||||||
|
resolver: zodResolver(FormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
//billType: "Rent",
|
||||||
|
recurrence: "No recurrence",
|
||||||
|
includeUserInSplit: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: z.infer<typeof FormSchema>) => {
|
||||||
|
console.log('API received data:', data);
|
||||||
|
try {
|
||||||
|
console.log('API received data:', data);
|
||||||
|
//const res = await fetch("/api/bills/createBill", {
|
||||||
|
//method: "POST",
|
||||||
|
//headers: {
|
||||||
|
//"Content-Type": "application/json",
|
||||||
|
//},
|
||||||
|
//body: JSON.stringify({
|
||||||
|
//date: date,
|
||||||
|
//billType: data.billType,
|
||||||
|
//billDescription: data.billDescription,
|
||||||
|
//amount: data.amount,
|
||||||
|
//recurrence: data.recurrence,
|
||||||
|
//includeUserInSplit: data.includeUserInSplit,
|
||||||
|
//roommates: data.roommates,
|
||||||
|
//}),
|
||||||
|
//});
|
||||||
|
//if (!res.ok) {
|
||||||
|
//throw new Error("Failed to create bill");
|
||||||
|
//}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Could not create bill", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DrawerContent className="w-full mx-auto items-center justify-center">
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle className="text-center md:text-2xl">
|
||||||
|
{date.toDateString()}
|
||||||
|
</DrawerTitle>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="flex flex-row w-full mx-auto justify-center">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<div className="flex flex-row mt-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="billType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel className="my-auto">
|
||||||
|
Bill Type
|
||||||
|
</FormLabel>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className={cn(
|
||||||
|
"w-[160px] justify-between mr-2",
|
||||||
|
!field.value && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{field.value
|
||||||
|
? billTypeCombo.find(
|
||||||
|
(billType) => billType.value === field.value
|
||||||
|
)?.label
|
||||||
|
: "Select bill type"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px]">
|
||||||
|
<Command>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No bill type found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{billTypeCombo.map((billType) => (
|
||||||
|
<CommandItem
|
||||||
|
value={billType.label}
|
||||||
|
key={billType.value}
|
||||||
|
onSelect={() => {
|
||||||
|
form.setValue("billType", billType.value as typeof field.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
billType.value === field.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{billType.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="billDescription"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Bill Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Optional Bill Description or any additional notes"
|
||||||
|
{...field}
|
||||||
|
className="md:w-[400px]"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row my-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="amount"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mr-2">
|
||||||
|
<FormLabel>Amount</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Amount" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="recurrence"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel className="my-auto">Recurrence</FormLabel>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className={cn(
|
||||||
|
"w-[160px] justify-between mr-2",
|
||||||
|
!field.value && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{field.value
|
||||||
|
? recurrenceCombo.find(
|
||||||
|
(recurrence) => recurrence.value === field.value
|
||||||
|
)?.label
|
||||||
|
: "Select recurrence"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px]">
|
||||||
|
<Command>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No recurrence found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{recurrenceCombo.map((recurrence) => (
|
||||||
|
<CommandItem
|
||||||
|
value={recurrence.label}
|
||||||
|
key={recurrence.value}
|
||||||
|
onSelect={() => {
|
||||||
|
form.setValue("recurrence", recurrence.value as typeof field.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
recurrence.value === field.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{recurrence.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="includeUserInSplit"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mt-10">
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<FormLabel>Include Yourself in Bill Split?</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
className="ml-2"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="roommates"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Roommates</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
</FormField>
|
||||||
|
<div className="w-full flex justify-end">
|
||||||
|
<Button type="submit" variant="secondary"
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
<DrawerFooter>
|
||||||
|
<DrawerClose />
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
);
|
||||||
|
}
|
42
src/components/portal/billtracker/CreateBillForm.tsx
Executable file
42
src/components/portal/billtracker/CreateBillForm.tsx
Executable file
@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerTrigger,
|
||||||
|
} from "~/components/ui/drawer"
|
||||||
|
import CreateBillDrawer from "~/components/portal/billtracker/CreateBillDrawer"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
|
||||||
|
type CreateBillFormProps = {
|
||||||
|
date: Date;
|
||||||
|
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create Bill Form. Get date from calendar
|
||||||
|
export default function CreateBillForm({date, setIsOpen}: CreateBillFormProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-row w-full">
|
||||||
|
<h3 className="font-medium leading-none
|
||||||
|
text-center mx-auto mt-2 py-2 md:text-xl">
|
||||||
|
{date.toDateString()}
|
||||||
|
</h3>
|
||||||
|
<button className="justify-self-end ml-auto bg-none
|
||||||
|
text-primary text-m md:text-xl"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Drawer>
|
||||||
|
<DrawerTrigger className="w-full">
|
||||||
|
<Button variant="outline" size="icon"
|
||||||
|
className="border-none w-full"
|
||||||
|
>
|
||||||
|
Create new Bill
|
||||||
|
</Button>
|
||||||
|
</DrawerTrigger>
|
||||||
|
<CreateBillDrawer date={date} />
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
33
src/components/portal/billtracker/ServerBillDrawer.tsx
Executable file
33
src/components/portal/billtracker/ServerBillDrawer.tsx
Executable file
@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from "~/components/ui/drawer"
|
||||||
|
import BillForm from "~/components/portal/billtracker/BillForm"
|
||||||
|
|
||||||
|
type CreateBillDrawerProps = {
|
||||||
|
date: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CreateBillDrawer({date}: CreateBillDrawerProps) {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DrawerContent className="w-full mx-auto items-center justify-center">
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle className="text-center md:text-2xl">
|
||||||
|
{date.toDateString()}
|
||||||
|
</DrawerTitle>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="flex flex-row w-full mx-auto justify-center">
|
||||||
|
<BillForm />
|
||||||
|
</div>
|
||||||
|
<DrawerFooter>
|
||||||
|
<DrawerClose />
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
);
|
||||||
|
}
|
27
src/components/portal/home/Hero.tsx
Executable file
27
src/components/portal/home/Hero.tsx
Executable file
@ -0,0 +1,27 @@
|
|||||||
|
import { Outfit as FontSans } from "next/font/google";
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
const fontSans = FontSans({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-sans",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function Hero() {
|
||||||
|
return (
|
||||||
|
<Link href="/">
|
||||||
|
<div className="flex flex-col justify-start items-start">
|
||||||
|
<h1 className={cn("text-4xl md:text-5xl lg:text-6xl font-bold text-center font-sans antialiased",
|
||||||
|
fontSans.variable)}
|
||||||
|
>
|
||||||
|
TENANT
|
||||||
|
</h1>
|
||||||
|
<h1 className={cn("text-4xl md:text-5xl lg:text-6xl font-bold text-center font-sans antialiased",
|
||||||
|
fontSans.variable)}
|
||||||
|
>
|
||||||
|
PORTAL
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
48
src/components/portal/home/NavBar.tsx
Executable file
48
src/components/portal/home/NavBar.tsx
Executable file
@ -0,0 +1,48 @@
|
|||||||
|
import { Outfit as FontSans } from "next/font/google";
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
import Link from "next/link"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
} from "~/components/ui/card"
|
||||||
|
|
||||||
|
const fontSans = FontSans({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-sans",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function Nav_Bar() {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col justify-start items-start h-5/6 my-auto" +
|
||||||
|
"py-6 text-lg md:text-xl lg:text-2xl font-semibold font-sans antialiased", fontSans.variable)}
|
||||||
|
>
|
||||||
|
<Card className="md:p-4">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<Link href="/">
|
||||||
|
Payments
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<Link href="/">
|
||||||
|
Workorders
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<Link href="/">
|
||||||
|
Messages
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<Link href="/">
|
||||||
|
Documents
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<Link href="/account/billtracker">
|
||||||
|
Bill Tracker
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
0
src/components/home/Title.tsx → src/components/portal/home/Title.tsx
Normal file → Executable file
0
src/components/home/Title.tsx → src/components/portal/home/Title.tsx
Normal file → Executable file
31
src/components/portal/home/breadcrumb/BreadCrumbBillTracker.tsx
Executable file
31
src/components/portal/home/breadcrumb/BreadCrumbBillTracker.tsx
Executable file
@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "~/components/ui/breadcrumb"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
export default function Breadcrumb_Home() {
|
||||||
|
return (
|
||||||
|
<Breadcrumb className="w-full m-auto flex flex-row justify-center items-center">
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<Link href="/">
|
||||||
|
<h1 className="text-xl pl-20 pt-4 lg:text-3xl lg:pl-0 lg:pt-0 font-bold text-center font-sans antialiased">
|
||||||
|
Dashboard
|
||||||
|
</h1>
|
||||||
|
</Link>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator className="pt-4 lg:pt-0"/>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<Link href="/billtracker">
|
||||||
|
<h1 className="text-xl pt-4 lg:text-3xl lg:pt-0 font-bold text-center font-sans antialiased">
|
||||||
|
Bill Tracker
|
||||||
|
</h1>
|
||||||
|
</Link>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
);
|
||||||
|
};
|
22
src/components/portal/home/breadcrumb/BreadcrumbHome.tsx
Executable file
22
src/components/portal/home/breadcrumb/BreadcrumbHome.tsx
Executable file
@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbList,
|
||||||
|
} from "~/components/ui/breadcrumb"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
export default function Breadcrumb_Home() {
|
||||||
|
return (
|
||||||
|
<Breadcrumb className="w-full m-auto flex flex-row justify-center items-center">
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<Link href="/account">
|
||||||
|
<h1 className="text-3xl font-bold text-center font-sans antialiased">
|
||||||
|
Dashboard
|
||||||
|
</h1>
|
||||||
|
</Link>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
);
|
||||||
|
};
|
0
src/components/theme/theme_provider.tsx
Normal file → Executable file
0
src/components/theme/theme_provider.tsx
Normal file → Executable file
4
src/components/theme/theme_toggle.tsx
Normal file → Executable file
4
src/components/theme/theme_toggle.tsx
Normal file → Executable file
@ -13,9 +13,10 @@ import {
|
|||||||
export default function Theme_Toggle() {
|
export default function Theme_Toggle() {
|
||||||
const { setTheme } = useTheme()
|
const { setTheme } = useTheme()
|
||||||
return (
|
return (
|
||||||
|
<div className="mx-2">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="icon">
|
<Button variant="outline" size="icon" className="border-none">
|
||||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">Toggle theme</span>
|
||||||
@ -33,5 +34,6 @@ export default function Theme_Toggle() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
65
src/components/ui/BillTrackerCalendar.tsx
Executable file
65
src/components/ui/BillTrackerCalendar.tsx
Executable file
@ -0,0 +1,65 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||||
|
import { DayPicker } from "react-day-picker"
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
import { buttonVariants } from "~/components/ui/button"
|
||||||
|
|
||||||
|
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
...props
|
||||||
|
}: CalendarProps) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn("p-3", className)}
|
||||||
|
classNames={{
|
||||||
|
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||||
|
month: "space-y-4",
|
||||||
|
caption: "flex justify-center pt-1 relative items-center",
|
||||||
|
caption_label: "text-2xl lg:text-4xl font-semibold p-2",
|
||||||
|
nav: "space-x-1 flex items-center",
|
||||||
|
nav_button: cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"h-12 w-12 lg:h-18 lg:w-18 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||||
|
),
|
||||||
|
nav_button_previous: "absolute left-1",
|
||||||
|
nav_button_next: "absolute right-1",
|
||||||
|
table: "w-full border-collapse space-y-1",
|
||||||
|
head_row: "flex",
|
||||||
|
head_cell:
|
||||||
|
"text-muted-foreground rounded-md w-16 lg:w-24 font-normal text-[1.2rem]",
|
||||||
|
row: "flex w-full mt-2",
|
||||||
|
cell: "h-16 w-16 lg:h-24 lg:w-24 text-center text-sm lg:text-xl p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||||
|
day: cn(
|
||||||
|
buttonVariants({ variant: "ghost" }),
|
||||||
|
"h-16 w-16 lg:h-24 lg:w-24 p-0 font-normal aria-selected:opacity-100"
|
||||||
|
),
|
||||||
|
day_range_end: "day-range-end",
|
||||||
|
day_selected:
|
||||||
|
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||||
|
day_today: "bg-accent text-accent-foreground",
|
||||||
|
day_outside:
|
||||||
|
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
|
||||||
|
day_disabled: "text-muted-foreground opacity-50",
|
||||||
|
day_range_middle:
|
||||||
|
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||||
|
day_hidden: "invisible",
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
IconLeft: ({ ...props }) => <ChevronLeft className="h-6 w-6" />,
|
||||||
|
IconRight: ({ ...props }) => <ChevronRight className="h-6 w-6" />,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Calendar.displayName = "Calendar"
|
||||||
|
|
||||||
|
export { Calendar }
|
141
src/components/ui/alert-dialog.tsx
Executable file
141
src/components/ui/alert-dialog.tsx
Executable file
@ -0,0 +1,141 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
import { buttonVariants } from "~/components/ui/button"
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
))
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogDescription.displayName =
|
||||||
|
AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"mt-2 sm:mt-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
50
src/components/ui/avatar.tsx
Executable file
50
src/components/ui/avatar.tsx
Executable file
@ -0,0 +1,50 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
115
src/components/ui/breadcrumb.tsx
Executable file
115
src/components/ui/breadcrumb.tsx
Executable file
@ -0,0 +1,115 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
const Breadcrumb = React.forwardRef<
|
||||||
|
HTMLElement,
|
||||||
|
React.ComponentPropsWithoutRef<"nav"> & {
|
||||||
|
separator?: React.ReactNode
|
||||||
|
}
|
||||||
|
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
||||||
|
Breadcrumb.displayName = "Breadcrumb"
|
||||||
|
|
||||||
|
const BreadcrumbList = React.forwardRef<
|
||||||
|
HTMLOListElement,
|
||||||
|
React.ComponentPropsWithoutRef<"ol">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ol
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
BreadcrumbList.displayName = "BreadcrumbList"
|
||||||
|
|
||||||
|
const BreadcrumbItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentPropsWithoutRef<"li">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<li
|
||||||
|
ref={ref}
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
BreadcrumbItem.displayName = "BreadcrumbItem"
|
||||||
|
|
||||||
|
const BreadcrumbLink = React.forwardRef<
|
||||||
|
HTMLAnchorElement,
|
||||||
|
React.ComponentPropsWithoutRef<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
>(({ asChild, className, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
className={cn("transition-colors hover:text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
BreadcrumbLink.displayName = "BreadcrumbLink"
|
||||||
|
|
||||||
|
const BreadcrumbPage = React.forwardRef<
|
||||||
|
HTMLSpanElement,
|
||||||
|
React.ComponentPropsWithoutRef<"span">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("font-normal text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
BreadcrumbPage.displayName = "BreadcrumbPage"
|
||||||
|
|
||||||
|
const BreadcrumbSeparator = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) => (
|
||||||
|
<li
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
||||||
|
|
||||||
|
const BreadcrumbEllipsis = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) => (
|
||||||
|
<span
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
0
src/components/ui/button.tsx
Normal file → Executable file
0
src/components/ui/button.tsx
Normal file → Executable file
66
src/components/ui/calendar.tsx
Normal file
66
src/components/ui/calendar.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||||
|
import { DayPicker } from "react-day-picker"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
import { buttonVariants } from "~/components/ui/button"
|
||||||
|
|
||||||
|
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
...props
|
||||||
|
}: CalendarProps) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn("p-3", className)}
|
||||||
|
classNames={{
|
||||||
|
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||||
|
month: "space-y-4",
|
||||||
|
caption: "flex justify-center pt-1 relative items-center",
|
||||||
|
caption_label: "text-sm font-medium",
|
||||||
|
nav: "space-x-1 flex items-center",
|
||||||
|
nav_button: cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||||
|
),
|
||||||
|
nav_button_previous: "absolute left-1",
|
||||||
|
nav_button_next: "absolute right-1",
|
||||||
|
table: "w-full border-collapse space-y-1",
|
||||||
|
head_row: "flex",
|
||||||
|
head_cell:
|
||||||
|
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||||
|
row: "flex w-full mt-2",
|
||||||
|
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||||
|
day: cn(
|
||||||
|
buttonVariants({ variant: "ghost" }),
|
||||||
|
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
|
||||||
|
),
|
||||||
|
day_range_end: "day-range-end",
|
||||||
|
day_selected:
|
||||||
|
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||||
|
day_today: "bg-accent text-accent-foreground",
|
||||||
|
day_outside:
|
||||||
|
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
|
||||||
|
day_disabled: "text-muted-foreground opacity-50",
|
||||||
|
day_range_middle:
|
||||||
|
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||||
|
day_hidden: "invisible",
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
||||||
|
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Calendar.displayName = "Calendar"
|
||||||
|
|
||||||
|
export { Calendar }
|
79
src/components/ui/card.tsx
Executable file
79
src/components/ui/card.tsx
Executable file
@ -0,0 +1,79 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
365
src/components/ui/chart.tsx
Normal file
365
src/components/ui/chart.tsx
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RechartsPrimitive from "recharts"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
|
|
||||||
|
export type ChartConfig = {
|
||||||
|
[k in string]: {
|
||||||
|
label?: React.ReactNode
|
||||||
|
icon?: React.ComponentType
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContainer = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig
|
||||||
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>["children"]
|
||||||
|
}
|
||||||
|
>(({ id, className, children, config, ...props }, ref) => {
|
||||||
|
const uniqueId = React.useId()
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-chart={chartId}
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
ChartContainer.displayName = "Chart"
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([_, config]) => config.theme || config.color
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||||
|
itemConfig.color
|
||||||
|
return color ? ` --color-${key}: ${color};` : null
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||||
|
|
||||||
|
const ChartTooltipContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean
|
||||||
|
hideIndicator?: boolean
|
||||||
|
indicator?: "line" | "dot" | "dashed"
|
||||||
|
nameKey?: string
|
||||||
|
labelKey?: string
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload
|
||||||
|
const key = `${labelKey || item.dataKey || item.name || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string"
|
||||||
|
? config[label as keyof typeof config]?.label || label
|
||||||
|
: itemConfig?.label
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>
|
||||||
|
{labelFormatter(value, payload)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload.map((item, index) => {
|
||||||
|
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const indicatorColor = color || item.payload.fill || item.color
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
|
indicator === "dot" && "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label || item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value && (
|
||||||
|
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ChartTooltipContent.displayName = "ChartTooltip"
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend
|
||||||
|
|
||||||
|
const ChartLegendContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> &
|
||||||
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||||
|
hideIcon?: boolean
|
||||||
|
nameKey?: string
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload.map((item) => {
|
||||||
|
const key = `${nameKey || item.dataKey || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ChartLegendContent.displayName = "ChartLegend"
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload &&
|
||||||
|
typeof payload.payload === "object" &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
let configLabelKey: string = key
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key as keyof typeof payload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[
|
||||||
|
key as keyof typeof payloadPayload
|
||||||
|
] as string
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config
|
||||||
|
? config[configLabelKey]
|
||||||
|
: config[key as keyof typeof config]
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
}
|
30
src/components/ui/checkbox.tsx
Executable file
30
src/components/ui/checkbox.tsx
Executable file
@ -0,0 +1,30 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { Check } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
className={cn("flex items-center justify-center text-current")}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
))
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Checkbox }
|
155
src/components/ui/command.tsx
Executable file
155
src/components/ui/command.tsx
Executable file
@ -0,0 +1,155 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { Search } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
import { Dialog, DialogContent } from "~/components/ui/dialog"
|
||||||
|
|
||||||
|
const Command = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Command.displayName = CommandPrimitive.displayName
|
||||||
|
|
||||||
|
interface CommandDialogProps extends DialogProps {}
|
||||||
|
|
||||||
|
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandInput = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||||
|
|
||||||
|
const CommandList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandList.displayName = CommandPrimitive.List.displayName
|
||||||
|
|
||||||
|
const CommandEmpty = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
|
>((props, ref) => (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
ref={ref}
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||||
|
|
||||||
|
const CommandGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||||
|
|
||||||
|
const CommandSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const CommandItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const CommandShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CommandShortcut.displayName = "CommandShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
122
src/components/ui/dialog.tsx
Executable file
122
src/components/ui/dialog.tsx
Executable file
@ -0,0 +1,122 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
118
src/components/ui/drawer.tsx
Executable file
118
src/components/ui/drawer.tsx
Executable file
@ -0,0 +1,118 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
const Drawer = ({
|
||||||
|
shouldScaleBackground = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||||
|
<DrawerPrimitive.Root
|
||||||
|
shouldScaleBackground={shouldScaleBackground}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
Drawer.displayName = "Drawer"
|
||||||
|
|
||||||
|
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||||
|
|
||||||
|
const DrawerPortal = DrawerPrimitive.Portal
|
||||||
|
|
||||||
|
const DrawerClose = DrawerPrimitive.Close
|
||||||
|
|
||||||
|
const DrawerOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DrawerContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DrawerPortal>
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
))
|
||||||
|
DrawerContent.displayName = "DrawerContent"
|
||||||
|
|
||||||
|
const DrawerHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DrawerHeader.displayName = "DrawerHeader"
|
||||||
|
|
||||||
|
const DrawerFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DrawerFooter.displayName = "DrawerFooter"
|
||||||
|
|
||||||
|
const DrawerTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DrawerDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
}
|
0
src/components/ui/dropdown-menu.tsx
Normal file → Executable file
0
src/components/ui/dropdown-menu.tsx
Normal file → Executable file
178
src/components/ui/form.tsx
Executable file
178
src/components/ui/form.tsx
Executable file
@ -0,0 +1,178 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
ControllerProps,
|
||||||
|
FieldPath,
|
||||||
|
FieldValues,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
} 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<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext)
|
||||||
|
const itemContext = React.useContext(FormItemContext)
|
||||||
|
const { getFieldState, formState } = useFormContext()
|
||||||
|
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState)
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormItem.displayName = "FormItem"
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(error && "text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormLabel.displayName = "FormLabel"
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Slot>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormControl.displayName = "FormControl"
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormDescription.displayName = "FormDescription"
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message) : children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-sm font-medium text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormMessage.displayName = "FormMessage"
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
25
src/components/ui/input.tsx
Executable file
25
src/components/ui/input.tsx
Executable file
@ -0,0 +1,25 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
26
src/components/ui/label.tsx
Executable file
26
src/components/ui/label.tsx
Executable file
@ -0,0 +1,26 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
128
src/components/ui/navigation-menu.tsx
Executable file
128
src/components/ui/navigation-menu.tsx
Executable file
@ -0,0 +1,128 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
import { ChevronDown } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
const NavigationMenu = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 flex max-w-max flex-1 items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<NavigationMenuViewport />
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
|
))
|
||||||
|
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const NavigationMenuList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"group flex flex-1 list-none items-center justify-center space-x-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
|
||||||
|
|
||||||
|
const NavigationMenuItem = NavigationMenuPrimitive.Item
|
||||||
|
|
||||||
|
const navigationMenuTriggerStyle = cva(
|
||||||
|
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
|
||||||
|
)
|
||||||
|
|
||||||
|
const NavigationMenuTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}{" "}
|
||||||
|
<ChevronDown
|
||||||
|
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const NavigationMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const NavigationMenuLink = NavigationMenuPrimitive.Link
|
||||||
|
|
||||||
|
const NavigationMenuViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
||||||
|
<NavigationMenuPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
NavigationMenuViewport.displayName =
|
||||||
|
NavigationMenuPrimitive.Viewport.displayName
|
||||||
|
|
||||||
|
const NavigationMenuIndicator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Indicator
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||||
|
</NavigationMenuPrimitive.Indicator>
|
||||||
|
))
|
||||||
|
NavigationMenuIndicator.displayName =
|
||||||
|
NavigationMenuPrimitive.Indicator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuIndicator,
|
||||||
|
NavigationMenuViewport,
|
||||||
|
}
|
31
src/components/ui/popover.tsx
Executable file
31
src/components/ui/popover.tsx
Executable file
@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent }
|
28
src/components/ui/progress.tsx
Normal file
28
src/components/ui/progress.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||||
|
>(({ className, value, ...props }, ref) => (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
className="h-full w-full flex-1 bg-primary transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
))
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Progress }
|
160
src/components/ui/select.tsx
Normal file
160
src/components/ui/select.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
}
|
28
src/components/ui/slider.tsx
Normal file
28
src/components/ui/slider.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
const Slider = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none select-none items-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||||
|
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
))
|
||||||
|
Slider.displayName = SliderPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Slider }
|
31
src/components/ui/sonner.tsx
Executable file
31
src/components/ui/sonner.tsx
Executable file
@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner } from "sonner"
|
||||||
|
|
||||||
|
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
|
actionButton:
|
||||||
|
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||||
|
cancelButton:
|
||||||
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
117
src/components/ui/table.tsx
Executable file
117
src/components/ui/table.tsx
Executable file
@ -0,0 +1,117 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Table.displayName = "Table"
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = "TableHeader"
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableBody.displayName = "TableBody"
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableFooter.displayName = "TableFooter"
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableRow.displayName = "TableRow"
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = "TableHead"
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCell.displayName = "TableCell"
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCaption.displayName = "TableCaption"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
55
src/components/ui/tabs.tsx
Normal file
55
src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
22
src/components/ui/textarea.tsx
Normal file
22
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.ComponentProps<"textarea">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
129
src/components/ui/toast.tsx
Executable file
129
src/components/ui/toast.tsx
Executable file
@ -0,0 +1,129 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||||
|
VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
))
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm opacity-90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
}
|
35
src/components/ui/toaster.tsx
Executable file
35
src/components/ui/toaster.tsx
Executable file
@ -0,0 +1,35 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Toast,
|
||||||
|
ToastClose,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport,
|
||||||
|
} from "~/components/ui/toast"
|
||||||
|
import { useToast } from "~/components/ui/use-toast"
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && (
|
||||||
|
<ToastDescription>{description}</ToastDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
)
|
||||||
|
}
|
194
src/components/ui/use-toast.ts
Executable file
194
src/components/ui/use-toast.ts
Executable file
@ -0,0 +1,194 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
// Inspired by react-hot-toast library
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ToastActionElement,
|
||||||
|
ToastProps,
|
||||||
|
} from "~/components/ui/toast"
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string
|
||||||
|
title?: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
action?: ToastActionElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: "ADD_TOAST",
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||||
|
return count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType["ADD_TOAST"]
|
||||||
|
toast: ToasterToast
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["UPDATE_TOAST"]
|
||||||
|
toast: Partial<ToasterToast>
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["DISMISS_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["REMOVE_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId)
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_TOAST",
|
||||||
|
toastId: toastId,
|
||||||
|
})
|
||||||
|
}, TOAST_REMOVE_DELAY)
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ADD_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "UPDATE_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "DISMISS_TOAST": {
|
||||||
|
const { toastId } = action
|
||||||
|
|
||||||
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
|
// but I'll keep it here for simplicity
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId)
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "REMOVE_TOAST":
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = []
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] }
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action)
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, "id">
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId()
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TOAST",
|
||||||
|
toast: { ...props, id },
|
||||||
|
})
|
||||||
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_TOAST",
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState)
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState)
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast }
|
0
src/env.js
Normal file → Executable file
0
src/env.js
Normal file → Executable file
0
src/lib/utils.ts
Normal file → Executable file
0
src/lib/utils.ts
Normal file → Executable file
0
src/middleware.ts
Normal file → Executable file
0
src/middleware.ts
Normal file → Executable file
0
src/scripts/generate_apple_secret.ts
Normal file → Executable file
0
src/scripts/generate_apple_secret.ts
Normal file → Executable file
0
src/server/db/index.ts
Normal file → Executable file
0
src/server/db/index.ts
Normal file → Executable file
189
src/server/db/schema.ts
Normal file → Executable file
189
src/server/db/schema.ts
Normal file → Executable file
@ -1,29 +1,72 @@
|
|||||||
//import { sql } from "drizzle-orm";
|
|
||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
timestamp,
|
timestamp,
|
||||||
pgTable,
|
pgTable,
|
||||||
text,
|
text,
|
||||||
|
pgEnum,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
integer,
|
integer,
|
||||||
|
numeric,
|
||||||
} from "drizzle-orm/pg-core"
|
} from "drizzle-orm/pg-core"
|
||||||
import type { AdapterAccountType } from "next-auth/adapters"
|
|
||||||
import postgres from "postgres"
|
import postgres from "postgres"
|
||||||
import { drizzle } from "drizzle-orm/postgres-js"
|
import { drizzle } from "drizzle-orm/postgres-js"
|
||||||
|
import type { AdapterAccountType } from "next-auth/adapters"
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_URL ?? "";
|
const connectionString = process.env.DATABASE_URL ?? "";
|
||||||
const pool = postgres(connectionString, { max: 1 })
|
const pool = postgres(connectionString, { max: 1 })
|
||||||
export const db = drizzle(pool)
|
export const db = drizzle(pool)
|
||||||
|
|
||||||
export const users = pgTable("user", {
|
export const frequencyEnum = pgEnum("frequency", ["Monthly", "Bi-weekly", "Weekly"]);
|
||||||
|
export const workOrderStatusEnum = pgEnum("workOrderStatus", ["Pending", "Open", "Closed"]);
|
||||||
|
export const paymentTypeEnum = pgEnum("paymentType", ["Security Deposit", "Rent", "Late Fee", "Other"]);
|
||||||
|
export const paymentStatusEnum = pgEnum("paymentStatus", ["Pending", "Complete", "Late", "Refunded"]);
|
||||||
|
export const preferredDaysofWeekEnum = pgEnum("preferredDaysofWeek",
|
||||||
|
["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||||
|
);
|
||||||
|
export const propertyTypeEnum = pgEnum("propertyType", ["Apartment", "Condominium",
|
||||||
|
"Mobile Home", "Multi-Unit Home", "Single-Family Residence", "Townhouse"]
|
||||||
|
);
|
||||||
|
export const workOrderPriorityEnum = pgEnum("workOrderPriority", ["Low", "High"]
|
||||||
|
);
|
||||||
|
export const workOrderTypeEnum = pgEnum("workOrderType",
|
||||||
|
["Appliance Repair", "Carbon Monoxide Detector Installation", "Ceiling Fan Repair",
|
||||||
|
"Carpentry Repair", "Door Installation/Repair", "Drywall Installation/Repair",
|
||||||
|
"Electrical Repair", "Extermination", "Fire Alarm Maintenance", "Fencing Repair",
|
||||||
|
"Gutter Cleaning", "HVAC Repair", "Light Fixture Repair", "Mold Remediation",
|
||||||
|
"Painting", "Pest Control", "Pool/Hot Tub Maintenance", "Plumbing Repair",
|
||||||
|
"Roof Repair/Maintenance", "Septic System Maintenance", "Smoke Detector Replacement",
|
||||||
|
"Tile Flooring", "Tree Trimming/Cutting", "Water Treatment", "Well/Water Testing",
|
||||||
|
"Window Repair/Installation"]
|
||||||
|
);
|
||||||
|
export const billStatusEnum = pgEnum("billStatus",
|
||||||
|
["Awaiting Payment", "Paid", "Scheduled", "Late", "Refunded"]
|
||||||
|
);
|
||||||
|
export const billTypeEnum = pgEnum("billType",
|
||||||
|
["Rent", "Power", "Internet", "Gas", "Water", "Phone Bill", "Cable",
|
||||||
|
"Security Deposit", "Other"]
|
||||||
|
);
|
||||||
|
export const billPaymentTypeEnum = pgEnum("billPaymentType",
|
||||||
|
["Paid Online", "Zelle", "Cash", "Cash App", "Apple Pay"]
|
||||||
|
);
|
||||||
|
export const billRecurrenceEnum = pgEnum("billRecurrence",
|
||||||
|
["Monthly", "Bi-weekly", "Weekly", "Annually"]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const users = pgTable(
|
||||||
|
"user",
|
||||||
|
{
|
||||||
id: text("id")
|
id: text("id")
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => crypto.randomUUID()),
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
email: text("email").unique(),
|
email: text("email").unique().notNull(),
|
||||||
emailVerified: timestamp("emailVerified", { mode: "date" }),
|
emailVerified: timestamp("emailVerified", { mode: "date" }),
|
||||||
image: text("image"),
|
image: text("image"),
|
||||||
})
|
phoneNumber: text("phoneNumber"),
|
||||||
|
propertyID: text("propertyID").references(() => properties.id, { onDelete: "cascade" }),
|
||||||
|
stripeCustomerID: text("stripeCustomerID"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export const accounts = pgTable(
|
export const accounts = pgTable(
|
||||||
"account",
|
"account",
|
||||||
@ -49,13 +92,16 @@ export const accounts = pgTable(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
export const sessions = pgTable("session", {
|
export const sessions = pgTable(
|
||||||
|
"session",
|
||||||
|
{
|
||||||
sessionToken: text("sessionToken").primaryKey(),
|
sessionToken: text("sessionToken").primaryKey(),
|
||||||
userId: text("userId")
|
userId: text("userId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
expires: timestamp("expires", { mode: "date" }).notNull(),
|
expires: timestamp("expires", { mode: "date" }).notNull(),
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export const verificationTokens = pgTable(
|
export const verificationTokens = pgTable(
|
||||||
"verificationToken",
|
"verificationToken",
|
||||||
@ -91,3 +137,132 @@ export const authenticators = pgTable(
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const admins = pgTable(
|
||||||
|
"admin",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userID: text("userID").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const properties = pgTable(
|
||||||
|
"property",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
address1: text("address1").unique().notNull(),
|
||||||
|
address2: text("address2"),
|
||||||
|
city: text("city").notNull(),
|
||||||
|
state: text("state").notNull(),
|
||||||
|
zip: text("zip").notNull(),
|
||||||
|
monthlyRent: numeric("monthlyRent").notNull(),
|
||||||
|
securityDeposit: numeric("securityDeposit"),
|
||||||
|
leaseStartDate: timestamp("leaseStartDate").notNull(),
|
||||||
|
leaseEndDate: timestamp("leaseEndDate").notNull(),
|
||||||
|
propertyType: propertyTypeEnum("propertyType").notNull(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const payments = pgTable(
|
||||||
|
"payment",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userID: text("userID").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
stripePaymentID: text("stripeID"),
|
||||||
|
amount: numeric("amount").notNull(),
|
||||||
|
paymentDate: timestamp("paymentDate").notNull(),
|
||||||
|
paymentType: paymentTypeEnum("paymentType").notNull(),
|
||||||
|
paymentStatus: paymentStatusEnum("paymentStatus").notNull(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const autoPayments = pgTable(
|
||||||
|
"autoPayment",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userID: text("userID").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
amount: numeric("amount").notNull(),
|
||||||
|
frequency: frequencyEnum("frequency").notNull(),
|
||||||
|
preferredDayofWeek: preferredDaysofWeekEnum("preferredDayofWeek").notNull(),
|
||||||
|
startDate: timestamp("startDate").notNull(),
|
||||||
|
nextPaymentDate: timestamp("nextPaymentDate").notNull(),
|
||||||
|
stripePaymentMethodID: text("stripePaymentMethodID").notNull(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const workorders = pgTable(
|
||||||
|
"workorder",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userID: text("userID").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
date: timestamp("date").notNull(),
|
||||||
|
type: workOrderTypeEnum("type").notNull(),
|
||||||
|
status: workOrderStatusEnum("status").notNull().default("Pending"),
|
||||||
|
priority: workOrderPriorityEnum("priority").notNull().default("Low"),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const documents = pgTable(
|
||||||
|
"document",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
propertyID: text("propertyID").notNull().references(() => properties.id, { onDelete: "cascade" }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
type: text("type").notNull(),
|
||||||
|
file: text("file").notNull(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const emergencyContacts = pgTable(
|
||||||
|
"emergencyContact",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userID: text("userID").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
mobilePhoneNumber: text("mobilePhoneNumber"),
|
||||||
|
workPhoneNumber: text("workPhoneNumber"),
|
||||||
|
email: text("email"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const bills = pgTable(
|
||||||
|
"bill",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
billType: billTypeEnum("billType").notNull(),
|
||||||
|
billDescription: text("billDescription"),
|
||||||
|
createdBy: text("createdBy").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||||
|
dueDate: timestamp("dueDate").notNull(),
|
||||||
|
amount: numeric("amount").notNull(),
|
||||||
|
recurrence: billRecurrenceEnum("recurrence"),
|
||||||
|
attachmentUrl: text("attachmentUrl"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const billsSplitBetween = pgTable(
|
||||||
|
"billSplitBetween",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
billID: text("billID").notNull().references(() => bills.id, { onDelete: "cascade" }),
|
||||||
|
userID: text("userID").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
amount: numeric("amount").notNull(),
|
||||||
|
status: billStatusEnum("status").notNull(),
|
||||||
|
paymentType: billPaymentTypeEnum("paymentType"),
|
||||||
|
paidAt: timestamp("paidAt"),
|
||||||
|
attachmentUrl: text("attachmentUrl"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const billReminders = pgTable(
|
||||||
|
"billReminders",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
billID: text("billID").notNull().references(() => bills.id, { onDelete: "cascade" }),
|
||||||
|
userID: text("userID").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
reminderDate: timestamp("reminderDate").notNull(),
|
||||||
|
reminderSent: boolean("reminderSent").notNull().default(false),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
43
src/server/functions.ts
Executable file
43
src/server/functions.ts
Executable file
@ -0,0 +1,43 @@
|
|||||||
|
import "server-only"
|
||||||
|
import { db } from "~/server/db"
|
||||||
|
import * as schema from "~/server/db/schema"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
//import { sql } from "drizzle-orm"
|
||||||
|
|
||||||
|
export const set_users_name_by_email = async (users_name: string, users_email: string) => {
|
||||||
|
try {
|
||||||
|
await db.update(schema.users)
|
||||||
|
.set({ name: users_name })
|
||||||
|
.where(eq(schema.users.email, users_email))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user name:', error);
|
||||||
|
throw error; // Ensure we rethrow to be caught by the calling function
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const get_users_name_by_email = async (users_email: string) => {
|
||||||
|
const result = await db.select({
|
||||||
|
users_name: schema.users.name,
|
||||||
|
}).from(schema.users)
|
||||||
|
.where(eq(schema.users.email, users_email))
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const set_users_pfp_by_email = async (users_pfp: string, users_email: string) => {
|
||||||
|
try {
|
||||||
|
await db.update(schema.users)
|
||||||
|
.set({ image: users_pfp })
|
||||||
|
.where(eq(schema.users.email, users_email))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user pfp:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const get_users_pfp_by_email = async (users_email: string) => {
|
||||||
|
const result = await db.select({
|
||||||
|
users_pfp: schema.users.image,
|
||||||
|
}).from(schema.users)
|
||||||
|
.where(eq(schema.users.email, users_email))
|
||||||
|
return result;
|
||||||
|
}
|
0
src/styles/globals.css
Normal file → Executable file
0
src/styles/globals.css
Normal file → Executable file
1
tailwind.config.ts
Normal file → Executable file
1
tailwind.config.ts
Normal file → Executable file
@ -56,6 +56,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ["var(--font-sans)", ...fontFamily.sans],
|
sans: ["var(--font-sans)", ...fontFamily.sans],
|
||||||
|
outfit: ["var(--font-outfit)", ...fontFamily.sans],
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
|
0
tsconfig.json
Normal file → Executable file
0
tsconfig.json
Normal file → Executable file
Reference in New Issue
Block a user