mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
BREAKING: postgres + saas
This commit is contained in:
93
components/auth/login-form.tsx
Normal file
93
components/auth/login-form.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client"
|
||||
|
||||
import { FormError } from "@/components/forms/error"
|
||||
import { FormInput } from "@/components/forms/simple"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
|
||||
export function LoginForm() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [otp, setOtp] = useState("")
|
||||
const [isOtpSent, setIsOtpSent] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const handleSendOtp = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await authClient.emailOtp.sendVerificationOtp({
|
||||
email,
|
||||
type: "sign-in",
|
||||
})
|
||||
if (result.error) {
|
||||
setError(result.error.message || "Failed to send the code")
|
||||
return
|
||||
}
|
||||
setIsOtpSent(true)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to send the code")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyOtp = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await authClient.signIn.emailOtp({
|
||||
email,
|
||||
otp,
|
||||
})
|
||||
if (result.error) {
|
||||
setError("The code is invalid or expired")
|
||||
return
|
||||
}
|
||||
|
||||
router.push("/dashboard")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to verify the code")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={isOtpSent ? handleVerifyOtp : handleSendOtp} className="flex flex-col gap-4 w-full">
|
||||
<FormInput
|
||||
title="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={isOtpSent}
|
||||
/>
|
||||
|
||||
{isOtpSent && (
|
||||
<FormInput
|
||||
title="Check your email for the verification code"
|
||||
type="text"
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value)}
|
||||
required
|
||||
maxLength={6}
|
||||
pattern="[0-9]{6}"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : isOtpSent ? "Verify Code" : "Enter"}
|
||||
</Button>
|
||||
|
||||
{error && <FormError className="text-center">{error}</FormError>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
96
components/auth/signup-form.tsx
Normal file
96
components/auth/signup-form.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client"
|
||||
|
||||
import { FormInput } from "@/components/forms/simple"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
|
||||
export default function SignupForm() {
|
||||
const [name, setName] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [otp, setOtp] = useState("")
|
||||
const [isOtpSent, setIsOtpSent] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const handleSendOtp = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await authClient.emailOtp.sendVerificationOtp({
|
||||
email,
|
||||
type: "sign-in",
|
||||
})
|
||||
setIsOtpSent(true)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to send OTP")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyOtp = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await authClient.signIn.emailOtp({
|
||||
email,
|
||||
otp,
|
||||
})
|
||||
await authClient.updateUser({
|
||||
name,
|
||||
})
|
||||
router.push("/dashboard")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to verify OTP")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={isOtpSent ? handleVerifyOtp : handleSendOtp} className="flex flex-col gap-4 w-full">
|
||||
<FormInput
|
||||
title="Your Name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
disabled={isOtpSent}
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
title="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={isOtpSent}
|
||||
/>
|
||||
|
||||
{isOtpSent && (
|
||||
<FormInput
|
||||
title="Check your email for the verification code"
|
||||
type="text"
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value)}
|
||||
required
|
||||
maxLength={6}
|
||||
pattern="[0-9]{6}"
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : isOtpSent ? "Verify Code" : "Create Account"}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useNotification } from "@/app/context"
|
||||
import { uploadFilesAction } from "@/app/files/actions"
|
||||
import { useNotification } from "@/app/(app)/context"
|
||||
import { uploadFilesAction } from "@/app/(app)/files/actions"
|
||||
import { FormError } from "@/components/forms/error"
|
||||
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files"
|
||||
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/constants"
|
||||
import { Camera, Loader2 } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { startTransition, useState } from "react"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FiltersWidget } from "@/components/dashboard/filters-widget"
|
||||
import { ProjectsWidget } from "@/components/dashboard/projects-widget"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { getProjects } from "@/models/projects"
|
||||
import { getDashboardStats, getProjectStats } from "@/models/stats"
|
||||
@@ -8,11 +9,12 @@ import { TransactionFilters } from "@/models/transactions"
|
||||
import { ArrowDown, ArrowUp, BicepsFlexed } from "lucide-react"
|
||||
|
||||
export async function StatsWidget({ filters }: { filters: TransactionFilters }) {
|
||||
const projects = await getProjects()
|
||||
const stats = await getDashboardStats(filters)
|
||||
const user = await getCurrentUser()
|
||||
const projects = await getProjects(user.id)
|
||||
const stats = await getDashboardStats(user.id, filters)
|
||||
const statsPerProject = Object.fromEntries(
|
||||
await Promise.all(
|
||||
projects.map((project) => getProjectStats(project.code, filters).then((stats) => [project.code, stats]))
|
||||
projects.map((project) => getProjectStats(user.id, project.code, filters).then((stats) => [project.code, stats]))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardDescription, CardTitle } from "@/components/ui/card"
|
||||
import { ColoredText } from "@/components/ui/colored-text"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getSettings, updateSettings } from "@/models/settings"
|
||||
import { Banknote, ChartBarStacked, FolderOpenDot, Key, TextCursorInput, X } from "lucide-react"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import Link from "next/link"
|
||||
|
||||
export async function WelcomeWidget() {
|
||||
const settings = await getSettings()
|
||||
const user = await getCurrentUser()
|
||||
const settings = await getSettings(user.id)
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col md:flex-row items-start gap-10 p-10 w-full">
|
||||
<Card className="flex flex-col lg:flex-row items-start gap-10 p-10 w-full">
|
||||
<img src="/logo/1024.png" alt="Logo" className="w-64 h-64" />
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="text-2xl font-bold">Hey, I'm TaxHacker 👋</span>
|
||||
<span className="text-2xl font-bold">
|
||||
<ColoredText>Hey, I'm TaxHacker 👋</ColoredText>
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={async () => {
|
||||
"use server"
|
||||
await updateSettings("is_welcome_message_hidden", "true")
|
||||
await updateSettings(user.id, "is_welcome_message_hidden", "true")
|
||||
revalidatePath("/")
|
||||
}}
|
||||
>
|
||||
|
||||
57
components/emails/email-layout.tsx
Normal file
57
components/emails/email-layout.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from "react"
|
||||
|
||||
interface EmailLayoutProps {
|
||||
children: React.ReactNode
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export const EmailLayout: React.FC<EmailLayoutProps> = ({ children, preview = "" }) => (
|
||||
<html>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="supported-color-schemes" content="light" />
|
||||
{preview && <title>{preview}</title>}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #333;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
.logo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div className="container">{children}</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
31
components/emails/newsletter-welcome-email.tsx
Normal file
31
components/emails/newsletter-welcome-email.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from "react"
|
||||
import { EmailLayout } from "./email-layout"
|
||||
|
||||
export const NewsletterWelcomeEmail: React.FC = () => (
|
||||
<EmailLayout preview="Welcome to TaxHacker Newsletter!">
|
||||
<h2 style={{ color: "#4f46e5" }}>👋 Welcome to TaxHacker!</h2>
|
||||
|
||||
<p style={{ fontSize: "16px", lineHeight: "1.5", color: "#333" }}>
|
||||
Thank you for subscribing to our updates. We'll keep you updated about:
|
||||
</p>
|
||||
<ul
|
||||
style={{
|
||||
paddingLeft: "20px",
|
||||
fontSize: "16px",
|
||||
lineHeight: "1.5",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
<li>New features and improvements</li>
|
||||
<li>Our plans and timelines</li>
|
||||
<li>Updates about our SaaS version</li>
|
||||
</ul>
|
||||
<div style={{ marginTop: "30px", borderTop: "1px solid #eee", paddingTop: "20px" }}>
|
||||
<p style={{ fontSize: "16px", color: "#333" }}>
|
||||
Best regards,
|
||||
<br />
|
||||
The TaxHacker Team
|
||||
</p>
|
||||
</div>
|
||||
</EmailLayout>
|
||||
)
|
||||
38
components/emails/otp-email.tsx
Normal file
38
components/emails/otp-email.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from "react"
|
||||
import { EmailLayout } from "./email-layout"
|
||||
|
||||
interface OTPEmailProps {
|
||||
otp: string
|
||||
}
|
||||
|
||||
export const OTPEmail: React.FC<OTPEmailProps> = ({ otp }) => (
|
||||
<EmailLayout preview="Your TaxHacker verification code">
|
||||
<h2 style={{ textAlign: "center", color: "#4f46e5" }}>🔑 Your TaxHacker verification code</h2>
|
||||
<div
|
||||
style={{
|
||||
margin: "20px 0",
|
||||
padding: "20px",
|
||||
backgroundColor: "#f3f4f6",
|
||||
borderRadius: "6px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: "16px", marginBottom: "10px" }}>Your verification code is:</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold",
|
||||
color: "#4f46e5",
|
||||
letterSpacing: "2px",
|
||||
margin: "0",
|
||||
}}
|
||||
>
|
||||
{otp}
|
||||
</p>
|
||||
</div>
|
||||
<p style={{ fontSize: "14px", color: "#666", textAlign: "center" }}>This code will expire in 10 minutes.</p>
|
||||
<p style={{ fontSize: "14px", color: "#666", textAlign: "center" }}>
|
||||
If you didn't request this code, please ignore this email.
|
||||
</p>
|
||||
</EmailLayout>
|
||||
)
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useNotification } from "@/app/context"
|
||||
import { uploadFilesAction } from "@/app/files/actions"
|
||||
import { uploadTransactionFilesAction } from "@/app/transactions/actions"
|
||||
import { useNotification } from "@/app/(app)/context"
|
||||
import { uploadFilesAction } from "@/app/(app)/files/actions"
|
||||
import { uploadTransactionFilesAction } from "@/app/(app)/transactions/actions"
|
||||
import { AlertCircle, CloudUpload, Loader2 } from "lucide-react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { startTransition, useEffect, useRef, useState } from "react"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useNotification } from "@/app/context"
|
||||
import { uploadFilesAction } from "@/app/files/actions"
|
||||
import { useNotification } from "@/app/(app)/context"
|
||||
import { uploadFilesAction } from "@/app/(app)/files/actions"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files"
|
||||
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/constants"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ComponentProps, startTransition, useRef, useState } from "react"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export function FormError({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-red-500 mt-4 overflow-hidden">{children}</p>
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function FormError({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return <p className={cn("text-red-500 mt-4 overflow-hidden", className)}>{children}</p>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Currency } from "@prisma/client"
|
||||
import { SelectProps } from "@radix-ui/react-select"
|
||||
import { useMemo } from "react"
|
||||
import { FormSelect } from "./simple"
|
||||
@@ -12,7 +11,7 @@ export const FormSelectCurrency = ({
|
||||
...props
|
||||
}: {
|
||||
title: string
|
||||
currencies: Currency[]
|
||||
currencies: { code: string; name: string }[]
|
||||
emptyValue?: string
|
||||
placeholder?: string
|
||||
hideIfEmpty?: boolean
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { parseCSVAction, saveTransactionsAction } from "@/app/import/csv/actions"
|
||||
import { parseCSVAction, saveTransactionsAction } from "@/app/(app)/import/csv/actions"
|
||||
import { FormError } from "@/components/forms/error"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field } from "@prisma/client"
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { saveSettingsAction } from "@/app/settings/actions"
|
||||
import { saveSettingsAction } from "@/app/(app)/settings/actions"
|
||||
import { FormError } from "@/components/forms/error"
|
||||
import { FormSelectCategory } from "@/components/forms/select-category"
|
||||
import { FormSelectCurrency } from "@/components/forms/select-currency"
|
||||
import { FormSelectType } from "@/components/forms/select-type"
|
||||
import { FormInput } from "@/components/forms/simple"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Category, Currency } from "@prisma/client"
|
||||
import { CircleCheckBig } from "lucide-react"
|
||||
@@ -24,8 +23,6 @@ export default function GlobalSettingsForm({
|
||||
|
||||
return (
|
||||
<form action={saveAction} className="space-y-4">
|
||||
<FormInput title="App Title" name="app_title" defaultValue={settings.app_title} />
|
||||
|
||||
<FormSelectCurrency
|
||||
title="Default Currency"
|
||||
name="default_currency"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { fieldsToJsonSchema } from "@/app/ai/schema"
|
||||
import { saveSettingsAction } from "@/app/settings/actions"
|
||||
import { fieldsToJsonSchema } from "@/ai/schema"
|
||||
import { saveSettingsAction } from "@/app/(app)/settings/actions"
|
||||
import { FormError } from "@/components/forms/error"
|
||||
import { FormInput, FormTextarea } from "@/components/forms/simple"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardTitle } from "@/components/ui/card"
|
||||
import { IS_SELF_HOSTED_MODE } from "@/lib/constants"
|
||||
import { Field } from "@prisma/client"
|
||||
import { CircleCheckBig, Edit } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
@@ -17,14 +18,18 @@ export default function LLMSettingsForm({ settings, fields }: { settings: Record
|
||||
return (
|
||||
<>
|
||||
<form action={saveAction} className="space-y-4">
|
||||
<FormInput title="OpenAI API Key" name="openai_api_key" defaultValue={settings.openai_api_key} />
|
||||
{IS_SELF_HOSTED_MODE && (
|
||||
<FormInput title="OpenAI API Key" name="openai_api_key" defaultValue={settings.openai_api_key} />
|
||||
)}
|
||||
|
||||
<small className="text-muted-foreground">
|
||||
Get your API key from{" "}
|
||||
<a href="https://platform.openai.com/settings/organization/api-keys" target="_blank" className="underline">
|
||||
OpenAI Platform Console
|
||||
</a>
|
||||
</small>
|
||||
{IS_SELF_HOSTED_MODE && (
|
||||
<small className="text-muted-foreground">
|
||||
Get your API key from{" "}
|
||||
<a href="https://platform.openai.com/settings/organization/api-keys" target="_blank" className="underline">
|
||||
OpenAI Platform Console
|
||||
</a>
|
||||
</small>
|
||||
)}
|
||||
|
||||
<FormTextarea
|
||||
title="Prompt for File Analysis Form"
|
||||
|
||||
33
components/settings/profile-settings-form copy.tsx
Normal file
33
components/settings/profile-settings-form copy.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { saveProfileAction } from "@/app/(app)/settings/actions"
|
||||
import { FormError } from "@/components/forms/error"
|
||||
import { FormInput } from "@/components/forms/simple"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { User } from "@prisma/client"
|
||||
import { CircleCheckBig } from "lucide-react"
|
||||
import { useActionState } from "react"
|
||||
|
||||
export default function ProfileSettingsForm({ user }: { user: User }) {
|
||||
const [saveState, saveAction, pending] = useActionState(saveProfileAction, null)
|
||||
|
||||
return (
|
||||
<form action={saveAction} className="space-y-4">
|
||||
<FormInput title="Your Name" name="name" defaultValue={user.name || ""} />
|
||||
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
{saveState?.success && (
|
||||
<p className="text-green-500 flex flex-row items-center gap-2">
|
||||
<CircleCheckBig />
|
||||
Saved!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{saveState?.error && <FormError>{saveState.error}</FormError>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -2,15 +2,10 @@
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { useSidebar } from "@/components/ui/sidebar"
|
||||
import { APP_TITLE } from "@/lib/constants"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function MobileMenu({
|
||||
settings,
|
||||
unsortedFilesCount,
|
||||
}: {
|
||||
settings: Record<string, string>
|
||||
unsortedFilesCount: number
|
||||
}) {
|
||||
export default function MobileMenu({ unsortedFilesCount }: { unsortedFilesCount: number }) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
@@ -20,7 +15,7 @@ export default function MobileMenu({
|
||||
<AvatarFallback className="rounded-lg">AI</AvatarFallback>
|
||||
</Avatar>
|
||||
<Link href="/" className="text-lg font-bold">
|
||||
{settings.app_title}
|
||||
{APP_TITLE}
|
||||
</Link>
|
||||
<Link
|
||||
href="/unsorted"
|
||||
|
||||
76
components/sidebar/sidebar-user.tsx
Normal file
76
components/sidebar/sidebar-user.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { SidebarMenuButton } from "@/components/ui/sidebar"
|
||||
import { UserProfile } from "@/lib/auth"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { IS_SELF_HOSTED_MODE } from "@/lib/constants"
|
||||
import { LogOut, MoreVertical, User } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function SidebarUser({ profile }: { profile: UserProfile }) {
|
||||
const signOut = async () => {
|
||||
await authClient.signOut({})
|
||||
redirect("/")
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="default"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="h-6 w-6 rounded-full bg-sidebar-accent">
|
||||
<AvatarImage src={profile.avatar} alt={profile.name || ""} />
|
||||
<AvatarFallback className="rounded-full bg-sidebar-accent text-sidebar-accent-foreground">
|
||||
<User className="h-4 w-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate font-medium">{profile.name || profile.email}</span>
|
||||
<MoreVertical className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
side={"top"}
|
||||
align="center"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
{/* <DropdownMenuItem>
|
||||
<ThemeToggle />
|
||||
</DropdownMenuItem> */}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/profile" className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
Profile Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{/* <DropdownMenuItem asChild>
|
||||
<Link href="/settings/billing" className="flex items-center gap-2">
|
||||
<CreditCard className="h-4 w-4" />
|
||||
Your Subscription
|
||||
</Link>
|
||||
</DropdownMenuItem> */}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
{!IS_SELF_HOSTED_MODE && (
|
||||
<DropdownMenuItem asChild>
|
||||
<span onClick={signOut} className="flex items-center gap-2 text-red-600 cursor-pointer">
|
||||
<LogOut className="h-4 w-4" />
|
||||
Log out
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useNotification } from "@/app/context"
|
||||
import { useNotification } from "@/app/(app)/context"
|
||||
import { UploadButton } from "@/components/files/upload-button"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -17,20 +16,19 @@ import {
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { UserProfile } from "@/lib/auth"
|
||||
import { APP_TITLE, IS_SELF_HOSTED_MODE } from "@/lib/constants"
|
||||
import { ClockArrowUp, FileText, Import, LayoutDashboard, Settings, Sparkles, Upload } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
import { ColoredText } from "../ui/colored-text"
|
||||
import { Blinker } from "./blinker"
|
||||
import { SidebarMenuItemWithHighlight } from "./sidebar-item"
|
||||
import SidebarUser from "./sidebar-user"
|
||||
|
||||
export function AppSidebar({
|
||||
settings,
|
||||
unsortedFilesCount,
|
||||
}: {
|
||||
settings: Record<string, string>
|
||||
unsortedFilesCount: number
|
||||
}) {
|
||||
export function AppSidebar({ unsortedFilesCount, profile }: { unsortedFilesCount: number; profile: UserProfile }) {
|
||||
const { open, setOpenMobile } = useSidebar()
|
||||
const pathname = usePathname()
|
||||
const { notification } = useNotification()
|
||||
@@ -44,25 +42,14 @@ export function AppSidebar({
|
||||
<>
|
||||
<Sidebar variant="inset" collapsible="icon">
|
||||
<SidebarHeader>
|
||||
{open ? (
|
||||
<Link href="/" className="flex items-center gap-2 p-2">
|
||||
<Avatar className="h-12 w-12 rounded-lg">
|
||||
<AvatarImage src="/logo/256.png" />
|
||||
<AvatarFallback className="rounded-lg">AI</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left leading-tight">
|
||||
<span className="truncate font-semibold">{settings.app_title}</span>
|
||||
<span className="truncate text-xs">Beta</span>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="/">
|
||||
<Avatar className="h-10 w-10 rounded-lg">
|
||||
<AvatarImage src="/logo/256.png" />
|
||||
<AvatarFallback className="rounded-lg">AI</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Image src="/logo/256.png" alt="Logo" className="h-10 w-10 rounded-lg" width={40} height={40} />
|
||||
<div className="grid flex-1 text-left leading-tight">
|
||||
<span className="truncate font-semibold text-lg">
|
||||
<ColoredText>{APP_TITLE}</ColoredText>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
@@ -74,9 +61,9 @@ export function AppSidebar({
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItemWithHighlight href="/">
|
||||
<SidebarMenuItemWithHighlight href="/dashboard">
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href="/">
|
||||
<Link href="/dashboard">
|
||||
<LayoutDashboard />
|
||||
<span>Home</span>
|
||||
</Link>
|
||||
@@ -137,14 +124,16 @@ export function AppSidebar({
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href="https://vas3k.com/donate/" target="_blank">
|
||||
<Sparkles />
|
||||
Thank the author
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{IS_SELF_HOSTED_MODE && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href="https://vas3k.com/donate/" target="_blank">
|
||||
<Sparkles />
|
||||
Thank the author
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
{!open && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarTrigger />
|
||||
@@ -153,6 +142,15 @@ export function AppSidebar({
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarUser profile={profile} />
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
</>
|
||||
|
||||
43
components/sidebar/theme-toggle.tsx
Normal file
43
components/sidebar/theme-toggle.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export const ThemeToggle = () => {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// Ensure component is mounted to avoid hydration mismatch
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (theme === "dark") {
|
||||
setTheme("light")
|
||||
} else {
|
||||
setTheme("dark")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div onClick={toggleTheme} className="flex items-center gap-2 cursor-pointer">
|
||||
{theme === "dark" ? (
|
||||
<>
|
||||
<Sun className="h-4 w-4" />
|
||||
Light Mode
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Moon className="h-4 w-4" />
|
||||
Dark Mode
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { bulkDeleteTransactionsAction } from "@/app/transactions/actions"
|
||||
import { bulkDeleteTransactionsAction } from "@/app/(app)/transactions/actions"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { ChevronUp, Trash2 } from "lucide-react"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { createTransactionAction } from "@/app/transactions/actions"
|
||||
import { createTransactionAction } from "@/app/(app)/transactions/actions"
|
||||
import { FormError } from "@/components/forms/error"
|
||||
import { FormSelectCategory } from "@/components/forms/select-category"
|
||||
import { FormSelectCurrency } from "@/components/forms/select-currency"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { deleteTransactionAction, saveTransactionAction } from "@/app/transactions/actions"
|
||||
import { deleteTransactionAction, saveTransactionAction } from "@/app/(app)/transactions/actions"
|
||||
import { FormError } from "@/components/forms/error"
|
||||
import { FormSelectCategory } from "@/components/forms/select-category"
|
||||
import { FormSelectCurrency } from "@/components/forms/select-currency"
|
||||
@@ -47,10 +47,13 @@ export default function TransactionEditForm({
|
||||
projectCode: transaction.projectCode || settings.default_project,
|
||||
issuedAt: transaction.issuedAt ? format(transaction.issuedAt, "yyyy-MM-dd") : "",
|
||||
note: transaction.note || "",
|
||||
...extraFields.reduce((acc, field) => {
|
||||
acc[field.code] = transaction.extra?.[field.code as keyof typeof transaction.extra] || ""
|
||||
return acc
|
||||
}, {} as Record<string, any>),
|
||||
...extraFields.reduce(
|
||||
(acc, field) => {
|
||||
acc[field.code] = transaction.extra?.[field.code as keyof typeof transaction.extra] || ""
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>
|
||||
),
|
||||
})
|
||||
|
||||
const handleDelete = async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { updateFieldVisibilityAction } from "@/app/transactions/actions"
|
||||
import { updateFieldVisibilityAction } from "@/app/(app)/transactions/actions"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getCategories } from "@/models/categories"
|
||||
import { getCurrencies } from "@/models/currencies"
|
||||
import { getProjects } from "@/models/projects"
|
||||
@@ -13,10 +14,11 @@ import { getSettings } from "@/models/settings"
|
||||
import TransactionCreateForm from "./create"
|
||||
|
||||
export async function NewTransactionDialog({ children }: { children: React.ReactNode }) {
|
||||
const categories = await getCategories()
|
||||
const currencies = await getCurrencies()
|
||||
const settings = await getSettings()
|
||||
const projects = await getProjects()
|
||||
const user = await getCurrentUser()
|
||||
const categories = await getCategories(user.id)
|
||||
const currencies = await getCurrencies(user.id)
|
||||
const settings = await getSettings(user.id)
|
||||
const projects = await getProjects(user.id)
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { deleteTransactionFileAction, uploadTransactionFilesAction } from "@/app/transactions/actions"
|
||||
import { deleteTransactionFileAction, uploadTransactionFilesAction } from "@/app/(app)/transactions/actions"
|
||||
import { FilePreview } from "@/components/files/preview"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files"
|
||||
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/constants"
|
||||
import { File, Transaction } from "@prisma/client"
|
||||
import { Loader2, Upload, X } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
12
components/ui/colored-text.tsx
Normal file
12
components/ui/colored-text.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function ColoredText({
|
||||
children,
|
||||
className,
|
||||
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<span className={cn("bg-gradient-to-r from-pink-600 to-indigo-600 bg-clip-text text-transparent", className)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -37,8 +37,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
@@ -53,8 +52,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
@@ -114,8 +112,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
@@ -147,11 +144,7 @@ const DropdownMenuLabel = React.forwardRef<
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -161,41 +154,29 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuTrigger,
|
||||
}
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
"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-[0.8rem] 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-[0.8rem] font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { analyzeTransaction, retrieveAllAttachmentsForAI } from "@/app/ai/analyze"
|
||||
import { useNotification } from "@/app/context"
|
||||
import { deleteUnsortedFileAction, saveFileAsTransactionAction } from "@/app/unsorted/actions"
|
||||
import { useNotification } from "@/app/(app)/context"
|
||||
import { analyzeFileAction, deleteUnsortedFileAction, saveFileAsTransactionAction } from "@/app/(app)/unsorted/actions"
|
||||
import { FormConvertCurrency } from "@/components/forms/convert-currency"
|
||||
import { FormError } from "@/components/forms/error"
|
||||
import { FormSelectCategory } from "@/components/forms/select-category"
|
||||
@@ -40,10 +39,13 @@ export default function AnalyzeForm({
|
||||
|
||||
const fieldsMap = useMemo(
|
||||
() =>
|
||||
fields.reduce((acc, field) => {
|
||||
acc[field.code] = field
|
||||
return acc
|
||||
}, {} as Record<string, Field>),
|
||||
fields.reduce(
|
||||
(acc, field) => {
|
||||
acc[field.code] = field
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, Field>
|
||||
),
|
||||
[fields]
|
||||
)
|
||||
const extraFields = useMemo(() => fields.filter((field) => field.isExtra), [fields])
|
||||
@@ -62,10 +64,13 @@ export default function AnalyzeForm({
|
||||
issuedAt: "",
|
||||
note: "",
|
||||
text: "",
|
||||
...extraFields.reduce((acc, field) => {
|
||||
acc[field.code] = ""
|
||||
return acc
|
||||
}, {} as Record<string, string>),
|
||||
...extraFields.reduce(
|
||||
(acc, field) => {
|
||||
acc[field.code] = ""
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
}),
|
||||
[file.filename, settings, extraFields]
|
||||
)
|
||||
@@ -89,20 +94,10 @@ export default function AnalyzeForm({
|
||||
|
||||
const startAnalyze = async () => {
|
||||
setIsAnalyzing(true)
|
||||
setAnalyzeStep("Retrieving files...")
|
||||
setAnalyzeError("")
|
||||
try {
|
||||
const attachments = await retrieveAllAttachmentsForAI(file)
|
||||
|
||||
setAnalyzeStep("Analyzing...")
|
||||
const results = await analyzeTransaction(
|
||||
settings.prompt_analyse_new_file || process.env.PROMPT_ANALYSE_NEW_FILE || "",
|
||||
settings,
|
||||
fields,
|
||||
categories,
|
||||
projects,
|
||||
attachments
|
||||
)
|
||||
const results = await analyzeFileAction(file, settings, fields, categories, projects)
|
||||
|
||||
console.log("Analysis results:", results)
|
||||
|
||||
@@ -114,7 +109,6 @@ export default function AnalyzeForm({
|
||||
([_, value]) => value !== null && value !== undefined && value !== ""
|
||||
)
|
||||
)
|
||||
console.log("Setting form data:", nonEmptyFields)
|
||||
setFormData({ ...formData, ...nonEmptyFields })
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user