init: kovakemlak-crm project scaffold

- Next.js 16 + Appwrite multi-tenant emlak CRM
- Database: kovakemlak-db (properties, customers, customer_searches, property_matches, presentations, investors, activities, tenant_settings)
- Same stack as isletmem-kovakcrm (shadcn/ui template base)
- Modules: portföy, müşteri takibi, arama kriterleri, otomatik eşleştirme, sunum linki, yatırımcı portalı
This commit is contained in:
egecankomur
2026-05-05 04:37:04 +03:00
commit 37679e83e6
383 changed files with 53525 additions and 0 deletions
@@ -0,0 +1,32 @@
"use client"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import Image from "next/image"
export function ForbiddenError() {
const router = useRouter()
return (
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
<Image
src='https://ui.shadcn.com/placeholder.svg'
alt='placeholder image'
width={960}
height={540}
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
/>
<div className='text-center'>
<h1 className='mb-4 text-3xl font-bold'>403</h1>
<h2 className="mb-3 text-2xl font-semibold">Forbidden</h2>
<p>Access to this resource is forbidden. You don&apos;t have the necessary permissions to view this page.</p>
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
Contact Us
</Button>
</div>
</div>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { ForbiddenError } from "./components/forbidden-error"
export default function ForbiddenPage() {
return <ForbiddenError />
}
@@ -0,0 +1,32 @@
"use client"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import Image from "next/image"
export function InternalServerError() {
const router = useRouter()
return (
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
<Image
src='https://ui.shadcn.com/placeholder.svg'
alt='placeholder image'
width={960}
height={540}
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
/>
<div className='text-center'>
<h1 className='mb-4 text-3xl font-bold'>500</h1>
<h2 className="mb-3 text-2xl font-semibold">Internal Server Error</h2>
<p>Something went wrong on our end. We&apos;re working to fix the issue. Please try again later.</p>
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
Contact Us
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,5 @@
import { InternalServerError } from "./components/internal-server-error"
export default function InternalServerErrorPage() {
return <InternalServerError />
}
@@ -0,0 +1,32 @@
"use client"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import Image from "next/image"
export function NotFoundError() {
const router = useRouter()
return (
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
<Image
src='https://ui.shadcn.com/placeholder.svg'
alt='placeholder image'
width={960}
height={540}
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
/>
<div className='text-center'>
<h1 className='mb-4 text-3xl font-bold'>404</h1>
<h2 className="mb-3 text-2xl font-semibold">Page Not Found</h2>
<p>The page you are looking for doesn&apos;t exist or has been moved to another location.</p>
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
Contact Us
</Button>
</div>
</div>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { NotFoundError } from "./components/not-found-error"
export default function NotFoundPage() {
return <NotFoundError />
}
@@ -0,0 +1,32 @@
"use client"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import Image from "next/image"
export function UnauthorizedError() {
const router = useRouter()
return (
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
<Image
src='https://ui.shadcn.com/placeholder.svg'
alt='placeholder image'
width={960}
height={540}
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
/>
<div className='text-center'>
<h1 className='mb-4 text-3xl font-bold'>401</h1>
<h2 className="mb-3 text-2xl font-semibold">Unauthorized</h2>
<p>You don&apos;t have permission to access this resource. Please sign in or contact your administrator.</p>
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
Contact Us
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,5 @@
import { UnauthorizedError } from "./components/unauthorized-error"
export default function UnauthorizedPage() {
return <UnauthorizedError />
}
@@ -0,0 +1,32 @@
"use client"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import Image from "next/image"
export function UnderMaintenanceError() {
const router = useRouter()
return (
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
<Image
src='https://ui.shadcn.com/placeholder.svg'
alt='placeholder image'
width={960}
height={540}
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
/>
<div className='text-center'>
<h1 className='mb-4 text-3xl font-bold'>503</h1>
<h2 className="mb-3 text-2xl font-semibold">Under Maintenance</h2>
<p>The service is currently unavailable. Please try again later.</p>
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
Contact Us
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,5 @@
import { UnderMaintenanceError } from "./components/under-maintenance-error"
export default function UnderMaintenancePage() {
return <UnderMaintenanceError />
}
@@ -0,0 +1,37 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function ForgotPasswordForm2({
className,
...props
}: React.ComponentProps<"form">) {
return (
<form className={cn("flex flex-col gap-6", className)} {...props}>
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-2xl font-bold">Forgot your password?</h1>
<p className="text-muted-foreground text-sm text-balance">
Enter your email address and we&apos;ll send you a link to reset your password
</p>
</div>
<div className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="m@example.com" required />
</div>
<Button type="submit" className="w-full cursor-pointer">
Send Reset Link
</Button>
</div>
<div className="text-center text-sm">
Remember your password?{" "}
<a href="/auth/sign-in-2" className="underline underline-offset-4">
Back to sign in
</a>
</div>
</form>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { ForgotPasswordForm2 } from "./components/forgot-password-form-2"
import { Logo } from "@/components/logo"
import Link from "next/link"
import Image from "next/image"
export default function ForgotPassword2Page() {
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-4 p-6 md:p-10">
<div className="flex justify-center gap-2 md:justify-start">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
<Logo size={24} />
</div>
ShadcnStore
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-md">
<ForgotPasswordForm2 />
</div>
</div>
</div>
<div className="bg-muted relative hidden lg:block">
<Image
src="https://ui.shadcn.com/placeholder.svg"
alt="Image"
fill
className="object-cover dark:brightness-[0.95] dark:invert"
/>
</div>
</div>
)
}
@@ -0,0 +1,72 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Logo } from "@/components/logo"
import Link from "next/link"
import Image from "next/image"
export function ForgotPasswordForm3({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<form className="p-6 md:p-8">
<div className="flex flex-col gap-6">
<div className="flex justify-center mb-2">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
<Logo size={24} />
</div>
<span className="text-xl">ShadcnStore</span>
</Link>
</div>
<div className="flex flex-col items-center text-center">
<h1 className="text-2xl font-bold">Forgot your password?</h1>
<p className="text-muted-foreground text-balance">
Enter your email to reset your ShadcnStore account password
</p>
</div>
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
required
/>
</div>
<Button type="submit" className="w-full cursor-pointer">
Send Reset Link
</Button>
<div className="text-center text-sm">
Remember your password?{" "}
<a href="/auth/sign-in-3" className="underline underline-offset-4">
Back to sign in
</a>
</div>
</div>
</form>
<div className="bg-muted relative hidden md:block">
<Image
src="https://ui.shadcn.com/placeholder.svg"
alt="Image"
fill
className="object-cover dark:brightness-[0.95] dark:invert"
/>
</div>
</CardContent>
</Card>
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
and <a href="#">Privacy Policy</a>.
</div>
</div>
)
}
@@ -0,0 +1,9 @@
import { ForgotPasswordForm3 } from "./components/forgot-password-form-3"
export default function ForgotPassword3Page() {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<ForgotPasswordForm3 className="w-full max-w-sm md:max-w-4xl" />
</div>
)
}
@@ -0,0 +1,88 @@
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { ArrowLeft, Loader2, MailCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { forgotPasswordAction } from "@/lib/appwrite/auth-actions";
import { initialAuthState } from "@/lib/appwrite/auth-types";
export function ForgotPasswordForm1({ className, ...props }: React.ComponentProps<"div">) {
const [state, formAction, isPending] = useActionState(forgotPasswordAction, initialAuthState);
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Şifremi unuttum</CardTitle>
<CardDescription>
Email adresinizi girin, sıfırlama bağlantısı gönderelim.
</CardDescription>
</CardHeader>
<CardContent>
{state.ok ? (
<div className="flex flex-col items-center gap-3 py-4 text-center">
<div className="bg-primary/10 text-primary flex size-12 items-center justify-center rounded-full">
<MailCheck className="size-6" />
</div>
<p className="text-sm">
Bağlantı emailinize gönderildi. Gelen kutusunu kontrol edin.
</p>
<Link
href="/sign-in"
className="text-muted-foreground hover:text-foreground mt-2 flex items-center gap-1 text-sm underline-offset-4 hover:underline"
>
<ArrowLeft className="size-3.5" />
Giriş sayfasına dön
</Link>
</div>
) : (
<form action={formAction} className="flex flex-col gap-4">
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="ornek@firma.com"
autoComplete="email"
required
/>
</div>
{state.error && (
<p className="text-destructive text-sm text-center" role="alert">
{state.error}
</p>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Gönderiliyor...
</>
) : (
"Sıfırlama bağlantısı gönder"
)}
</Button>
<Link
href="/sign-in"
className="text-muted-foreground hover:text-foreground flex items-center justify-center gap-1 text-sm underline-offset-4 hover:underline"
>
<ArrowLeft className="size-3.5" />
Giriş sayfasına dön
</Link>
</form>
)}
</CardContent>
</Card>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import Link from "next/link";
import { ForgotPasswordForm1 } from "./components/forgot-password-form-1";
import { Logo } from "@/components/logo";
export default function ForgotPasswordPage() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<Link href="/" className="flex items-center gap-2 self-center font-medium">
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
<Logo size={24} />
</div>
<span className="text-lg font-semibold">İşletmem</span>
</Link>
<ForgotPasswordForm1 />
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "İşletmem — Giriş",
description: "İşletmem KovakCRM hesabınıza giriş yapın veya yeni hesap oluşturun.",
};
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return <div className="min-h-screen bg-background">{children}</div>;
}
@@ -0,0 +1,63 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function LoginForm2({
className,
...props
}: React.ComponentProps<"form">) {
return (
<form className={cn("flex flex-col gap-6", className)} {...props} action="/dashboard">
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-2xl font-bold">Login to your account</h1>
<p className="text-muted-foreground text-sm text-balance">
Enter your email below to login to your account
</p>
</div>
<div className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="test@example.com" defaultValue="test@example.com" required />
</div>
<div className="grid gap-3">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<a
href="/auth/forgot-password-2"
className="ml-auto text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
</div>
<Input id="password" type="password" defaultValue="password" required />
</div>
<Button type="submit" className="w-full cursor-pointer">
Login
</Button>
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-background text-muted-foreground relative z-10 px-2">
Or continue with
</span>
</div>
<Button variant="outline" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
fill="currentColor"
/>
</svg>
Login with GitHub
</Button>
</div>
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
<a href="/auth/sign-up-2" className="underline underline-offset-4">
Sign up
</a>
</div>
</form>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { LoginForm2 } from "./components/login-form-2"
import { Logo } from "@/components/logo"
import Link from "next/link"
import Image from "next/image"
export default function LoginPage() {
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-4 p-6 md:p-10">
<div className="flex justify-center gap-2 md:justify-start">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
<Logo size={24} />
</div>
ShadcnStore
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-md">
<LoginForm2 />
</div>
</div>
</div>
<div className="bg-muted relative hidden lg:block">
<Image
src="https://ui.shadcn.com/placeholder.svg"
alt="Image"
fill
className="object-cover dark:brightness-[0.95] dark:invert"
/>
</div>
</div>
)
}
@@ -0,0 +1,119 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Logo } from "@/components/logo"
import Link from "next/link"
import Image from "next/image"
export function LoginForm3({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<form className="p-6 md:p-8" action="/dashboard">
<div className="flex flex-col gap-6">
<div className="flex justify-center mb-2">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
<Logo size={24} />
</div>
<span className="text-xl">ShadcnStore</span>
</Link>
</div>
<div className="flex flex-col items-center text-center">
<h1 className="text-2xl font-bold">Welcome back</h1>
<p className="text-muted-foreground text-balance">
Login to your ShadcnStore account
</p>
</div>
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="test@example.com"
defaultValue="test@example.com"
required
/>
</div>
<div className="grid gap-3">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<a
href="/auth/forgot-password-3"
className="ml-auto text-sm underline-offset-2 hover:underline"
>
Forgot your password?
</a>
</div>
<Input id="password" type="password" defaultValue="password" required />
</div>
<Button type="submit" className="w-full cursor-pointer">
Login
</Button>
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-card text-muted-foreground relative z-10 px-2">
Or continue with
</span>
</div>
<div className="grid grid-cols-3 gap-4">
<Button variant="outline" type="button" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
fill="currentColor"
/>
</svg>
<span className="sr-only">Login with Apple</span>
</Button>
<Button variant="outline" type="button" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor"
/>
</svg>
<span className="sr-only">Login with Google</span>
</Button>
<Button variant="outline" type="button" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z"
fill="currentColor"
/>
</svg>
<span className="sr-only">Login with Meta</span>
</Button>
</div>
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
<a href="/auth/sign-up-3" className="underline underline-offset-4">
Sign up
</a>
</div>
</div>
</form>
<div className="bg-muted relative hidden md:block">
<Image
src="https://ui.shadcn.com/placeholder.svg"
alt="Image"
fill
className="object-cover dark:brightness-[0.95] dark:invert"
/>
</div>
</CardContent>
</Card>
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
and <a href="#">Privacy Policy</a>.
</div>
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
import { LoginForm3 } from "./components/login-form-3"
export default function LoginPage() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm md:max-w-4xl">
<LoginForm3 />
</div>
</div>
)
}
@@ -0,0 +1,169 @@
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Logo } from "@/components/logo";
import { cn } from "@/lib/utils";
import { signInAction } from "@/lib/appwrite/auth-actions";
import { initialAuthState } from "@/lib/appwrite/auth-types";
export function LoginForm1({
className,
inviteCode,
...props
}: React.ComponentProps<"div"> & { inviteCode?: string }) {
const [state, formAction, isPending] = useActionState(signInAction, initialAuthState);
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<form action={formAction} className="p-6 md:p-10">
{inviteCode && <input type="hidden" name="inviteCode" value={inviteCode} />}
<div className="flex flex-col gap-6">
<div className="flex justify-center">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
<Logo size={22} />
</div>
<span className="text-xl font-semibold">İşletmem</span>
</Link>
</div>
{inviteCode && (
<p className="text-muted-foreground rounded-md border bg-muted/50 px-3 py-2 text-center text-xs">
Davete katılmak için giriş yapın.
</p>
)}
<div className="flex flex-col items-center text-center">
<h1 className="text-2xl font-bold tracking-tight">Tekrar hoş geldiniz</h1>
<p className="text-muted-foreground text-sm text-balance mt-1">
Hesabınıza giriş yaparak işletmenizi yönetmeye devam edin
</p>
</div>
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="ornek@firma.com"
autoComplete="email"
required
/>
</div>
<div className="grid gap-3">
<div className="flex items-center">
<Label htmlFor="password">Şifre</Label>
<Link
href="/forgot-password"
className="ml-auto text-xs text-muted-foreground hover:text-foreground underline-offset-4 hover:underline"
>
Şifremi unuttum
</Link>
</div>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
/>
</div>
{state.error && (
<p className="text-destructive text-sm text-center" role="alert">
{state.error}
</p>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Giriş yapılıyor...
</>
) : (
"Giriş yap"
)}
</Button>
<div className="text-center text-sm text-muted-foreground">
Hesabınız yok mu?{" "}
<Link
href="/sign-up"
className="text-foreground font-medium underline-offset-4 hover:underline"
>
Hesap oluştur
</Link>
</div>
</div>
</form>
<BrandPanel />
</CardContent>
</Card>
<p className="text-muted-foreground text-center text-xs text-balance">
Giriş yaparak{" "}
<Link href="#" className="underline-offset-4 hover:underline">
Kullanım Şartları
</Link>{" "}
ve{" "}
<Link href="#" className="underline-offset-4 hover:underline">
Gizlilik Politikası
</Link>
&apos;nı kabul etmiş olursunuz.
</p>
</div>
);
}
function BrandPanel() {
return (
<div className="bg-primary text-primary-foreground relative hidden md:flex md:flex-col md:justify-between overflow-hidden p-10">
<div
className="absolute inset-0 opacity-30"
style={{
backgroundImage:
"radial-gradient(circle at 20% 20%, rgba(255,255,255,0.4) 0%, transparent 40%), radial-gradient(circle at 80% 80%, rgba(255,255,255,0.25) 0%, transparent 45%)",
}}
aria-hidden
/>
<div
className="absolute -top-24 -right-24 size-72 rounded-full bg-white/10 blur-3xl"
aria-hidden
/>
<div
className="absolute -bottom-32 -left-20 size-80 rounded-full bg-black/10 blur-3xl"
aria-hidden
/>
<div className="relative z-10 flex items-center gap-2">
<div className="bg-primary-foreground/15 ring-1 ring-primary-foreground/20 backdrop-blur flex size-10 items-center justify-center rounded-md">
<Logo size={22} />
</div>
<span className="text-lg font-medium">İşletmem</span>
</div>
<div className="relative z-10 flex flex-col gap-3">
<h2 className="text-3xl font-semibold leading-tight">
Müşteriden faturaya, tek panelden işletmenizi yönetin.
</h2>
<p className="text-primary-foreground/80 text-sm">
Müşteriler, hizmetler, takvim, görevler ve finans hepsi tek yerde, multi-tenant ve ekibinize özel.
</p>
<div className="text-primary-foreground/70 mt-4 text-xs">KovakSoft tarafından</div>
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { redirect } from "next/navigation";
import { LoginForm1 } from "./components/login-form-1";
import { getCurrentUser } from "@/lib/appwrite/server";
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ invite?: string }>;
}) {
const { invite } = await searchParams;
const user = await getCurrentUser();
if (user) redirect(invite ? `/d/${invite}` : "/dashboard");
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm md:max-w-4xl">
<LoginForm1 inviteCode={invite} />
</div>
</div>
);
}
@@ -0,0 +1,83 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
export function SignupForm2({
className,
...props
}: React.ComponentProps<"form">) {
return (
<form className={cn("flex flex-col gap-6", className)} {...props}>
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-2xl font-bold">Create your account</h1>
<p className="text-muted-foreground text-sm text-balance">
Enter your information to create a new account
</p>
</div>
<div className="grid gap-6">
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-3">
<Label htmlFor="firstName">First Name</Label>
<Input id="firstName" placeholder="John" required />
</div>
<div className="grid gap-3">
<Label htmlFor="lastName">Last Name</Label>
<Input id="lastName" placeholder="Doe" required />
</div>
</div>
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="m@example.com" required />
</div>
<div className="grid gap-3">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" required />
</div>
<div className="grid gap-3">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input id="confirmPassword" type="password" required />
</div>
<div className="flex items-center space-x-2">
<Checkbox id="terms" required />
<Label htmlFor="terms" className="text-sm">
I agree to the{" "}
<a href="#" className="underline underline-offset-4 hover:text-primary">
Terms of Service
</a>{" "}
and{" "}
<a href="#" className="underline underline-offset-4 hover:text-primary">
Privacy Policy
</a>
</Label>
</div>
<Button type="submit" className="w-full cursor-pointer">
Create Account
</Button>
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-background text-muted-foreground relative z-10 px-2">
Or continue with
</span>
</div>
<Button variant="outline" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
fill="currentColor"
/>
</svg>
Sign up with GitHub
</Button>
</div>
<div className="text-center text-sm">
Already have an account?{" "}
<a href="/auth/sign-in-2" className="underline underline-offset-4">
Sign in
</a>
</div>
</form>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { SignupForm2 } from "./components/signup-form-2"
import { Logo } from "@/components/logo"
import Link from "next/link"
import Image from "next/image"
export default function SignUp2Page() {
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-4 p-6 md:p-10">
<div className="flex justify-center gap-2 md:justify-start">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
<Logo size={24} />
</div>
ShadcnStore
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-md">
<SignupForm2 />
</div>
</div>
</div>
<div className="bg-muted relative hidden lg:block">
<Image
src="https://ui.shadcn.com/placeholder.svg"
alt="Image"
fill
className="object-cover dark:brightness-[0.95] dark:invert"
/>
</div>
</div>
)
}
@@ -0,0 +1,146 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import { Logo } from "@/components/logo"
import Link from "next/link"
import Image from "next/image"
export function SignupForm3({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<form className="p-6 md:p-8">
<div className="flex flex-col gap-6">
<div className="flex justify-center mb-2">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
<Logo size={24} />
</div>
<span className="text-xl">ShadcnStore</span>
</Link>
</div>
<div className="flex flex-col items-center text-center">
<h1 className="text-2xl font-bold">Create your account</h1>
<p className="text-muted-foreground text-balance">
Enter your information to create a new account
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-3">
<Label htmlFor="firstName">First Name</Label>
<Input
id="firstName"
placeholder="John"
required
/>
</div>
<div className="grid gap-3">
<Label htmlFor="lastName">Last Name</Label>
<Input
id="lastName"
placeholder="Doe"
required
/>
</div>
</div>
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
required
/>
</div>
<div className="grid gap-3">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" required />
</div>
<div className="grid gap-3">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input id="confirmPassword" type="password" required />
</div>
<div className="flex items-center space-x-2">
<Checkbox id="terms" required />
<Label htmlFor="terms" className="text-sm">
I agree to the{" "}
<a href="#" className="underline underline-offset-4 hover:text-primary">
Terms of Service
</a>{" "}
and{" "}
<a href="#" className="underline underline-offset-4 hover:text-primary">
Privacy Policy
</a>
</Label>
</div>
<Button type="submit" className="w-full cursor-pointer">
Create Account
</Button>
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-card text-muted-foreground relative z-10 px-2">
Or continue with
</span>
</div>
<div className="grid grid-cols-3 gap-4">
<Button variant="outline" type="button" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
fill="currentColor"
/>
</svg>
<span className="sr-only">Sign up with Apple</span>
</Button>
<Button variant="outline" type="button" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor"
/>
</svg>
<span className="sr-only">Sign up with Google</span>
</Button>
<Button variant="outline" type="button" className="w-full cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z"
fill="currentColor"
/>
</svg>
<span className="sr-only">Sign up with Meta</span>
</Button>
</div>
<div className="text-center text-sm">
Already have an account?{" "}
<a href="/auth/sign-in-3" className="underline underline-offset-4">
Sign in
</a>
</div>
</div>
</form>
<div className="bg-muted relative hidden md:block">
<Image
src="https://ui.shadcn.com/placeholder.svg"
alt="Image"
fill
className="object-cover dark:brightness-[0.95] dark:invert"
/>
</div>
</CardContent>
</Card>
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
and <a href="#">Privacy Policy</a>.
</div>
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { SignupForm3 } from "./components/signup-form-3"
export default function SignUp3Page() {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<SignupForm3 className="w-full max-w-5xl" />
</div>
)
}
@@ -0,0 +1,181 @@
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Logo } from "@/components/logo";
import { cn } from "@/lib/utils";
import { signUpAction } from "@/lib/appwrite/auth-actions";
import { initialAuthState } from "@/lib/appwrite/auth-types";
export function SignupForm1({
className,
inviteCode,
prefilledEmail,
...props
}: React.ComponentProps<"div"> & { inviteCode?: string; prefilledEmail?: string }) {
const [state, formAction, isPending] = useActionState(signUpAction, initialAuthState);
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<BrandPanel />
<form action={formAction} className="p-6 md:p-10">
{inviteCode && <input type="hidden" name="inviteCode" value={inviteCode} />}
<div className="flex flex-col gap-6">
<div className="flex justify-center">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
<Logo size={22} />
</div>
<span className="text-xl font-semibold">İşletmem</span>
</Link>
</div>
<div className="flex flex-col items-center text-center">
<h1 className="text-2xl font-bold tracking-tight">
{inviteCode ? "Davete katıl" : "Hesap oluşturun"}
</h1>
<p className="text-muted-foreground text-sm text-balance mt-1">
{inviteCode
? "Hesap oluşturduktan sonra çalışma alanına otomatik katılacaksınız"
: "Birkaç saniye içinde hesabınız hazır"}
</p>
</div>
<div className="grid gap-3">
<Label htmlFor="name">Adınız Soyadınız</Label>
<Input
id="name"
name="name"
type="text"
placeholder="Ahmet Yılmaz"
autoComplete="name"
required
/>
</div>
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="ornek@firma.com"
autoComplete="email"
defaultValue={prefilledEmail}
readOnly={Boolean(prefilledEmail)}
required
/>
</div>
<div className="grid gap-3">
<Label htmlFor="password">Şifre</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
minLength={8}
required
/>
<p className="text-muted-foreground text-xs">En az 8 karakter</p>
</div>
{state.error && (
<p className="text-destructive text-sm text-center" role="alert">
{state.error}
</p>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Hesap oluşturuluyor...
</>
) : (
"Hesap oluştur"
)}
</Button>
<div className="text-center text-sm text-muted-foreground">
Zaten hesabınız var mı?{" "}
<Link
href="/sign-in"
className="text-foreground font-medium underline-offset-4 hover:underline"
>
Giriş yap
</Link>
</div>
</div>
</form>
</CardContent>
</Card>
<p className="text-muted-foreground text-center text-xs text-balance">
Hesap oluşturarak{" "}
<Link href="#" className="underline-offset-4 hover:underline">
Kullanım Şartları
</Link>{" "}
ve{" "}
<Link href="#" className="underline-offset-4 hover:underline">
Gizlilik Politikası
</Link>
&apos;nı kabul etmiş olursunuz.
</p>
</div>
);
}
function BrandPanel() {
return (
<div className="bg-primary text-primary-foreground relative hidden md:flex md:flex-col md:justify-between overflow-hidden p-10">
<div
className="absolute inset-0 opacity-30"
style={{
backgroundImage:
"radial-gradient(circle at 20% 20%, rgba(255,255,255,0.4) 0%, transparent 40%), radial-gradient(circle at 80% 80%, rgba(255,255,255,0.25) 0%, transparent 45%)",
}}
aria-hidden
/>
<div
className="absolute -top-24 -left-24 size-72 rounded-full bg-white/10 blur-3xl"
aria-hidden
/>
<div
className="absolute -bottom-32 -right-20 size-80 rounded-full bg-black/10 blur-3xl"
aria-hidden
/>
<div className="relative z-10 flex items-center gap-2">
<div className="bg-primary-foreground/15 ring-1 ring-primary-foreground/20 backdrop-blur flex size-10 items-center justify-center rounded-md">
<Logo size={22} />
</div>
<span className="text-lg font-medium">İşletmem</span>
</div>
<div className="relative z-10 flex flex-col gap-3">
<h2 className="text-3xl font-semibold leading-tight">
İşletmenizi büyütecek tek araç.
</h2>
<p className="text-primary-foreground/80 text-sm">
Hesap oluşturduktan sonra çalışma alanınızı kuruyor, ekibinizi davet ediyor ve hemen kullanmaya başlıyorsunuz.
</p>
<ul className="text-primary-foreground/85 mt-2 space-y-1 text-sm">
<li> Müşteri & hizmet yönetimi</li>
<li> Görev ve takvim</li>
<li> Finans ve fatura</li>
</ul>
<div className="text-primary-foreground/70 mt-4 text-xs">KovakSoft tarafından</div>
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { redirect } from "next/navigation";
import { SignupForm1 } from "./components/signup-form-1";
import { getCurrentUser } from "@/lib/appwrite/server";
export default async function SignUpPage({
searchParams,
}: {
searchParams: Promise<{ invite?: string; email?: string }>;
}) {
const { invite, email } = await searchParams;
const user = await getCurrentUser();
if (user) redirect(invite ? `/d/${invite}` : "/dashboard");
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm md:max-w-4xl">
<SignupForm1 inviteCode={invite} prefilledEmail={email} />
</div>
</div>
);
}
@@ -0,0 +1,259 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { ChevronLeft, ChevronRight, Loader2, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { deleteCalendarEventAction } from "@/lib/appwrite/calendar-actions";
import { cn } from "@/lib/utils";
import { EventFormSheet } from "./event-form-sheet";
import { COLOR_BG, type Customer, type EventRow } from "./types";
type Props = {
events: EventRow[];
customers: Customer[];
};
const WEEKDAYS = ["Pzt", "Sal", "Çar", "Per", "Cum", "Cmt", "Paz"];
const MONTH_NAMES = [
"Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran",
"Temmuz", "Ağustos", "Eylül", "Ekim", "Kasım", "Aralık",
];
function startOfMonthGrid(year: number, month: number): Date {
// Monday-first grid; first cell is the Monday on/before the 1st
const first = new Date(year, month, 1);
const dayIdx = (first.getDay() + 6) % 7; // 0 = Mon
return new Date(year, month, 1 - dayIdx);
}
function ymd(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
export function CalendarClient({ events, customers }: Props) {
const today = new Date();
const [cursor, setCursor] = useState(new Date(today.getFullYear(), today.getMonth(), 1));
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<EventRow | null>(null);
const [defaultDate, setDefaultDate] = useState<string | undefined>();
const [deleting, setDeleting] = useState<EventRow | null>(null);
const [busy, startTransition] = useTransition();
const eventsByDay = useMemo(() => {
const map = new Map<string, EventRow[]>();
for (const e of events) {
const start = new Date(e.start);
const end = new Date(e.end);
const cur = new Date(start.getFullYear(), start.getMonth(), start.getDate());
const last = new Date(end.getFullYear(), end.getMonth(), end.getDate());
while (cur.getTime() <= last.getTime()) {
const key = ymd(cur);
const arr = map.get(key) ?? [];
arr.push(e);
map.set(key, arr);
cur.setDate(cur.getDate() + 1);
}
}
return map;
}, [events]);
const grid = useMemo(() => {
const start = startOfMonthGrid(cursor.getFullYear(), cursor.getMonth());
const days: Date[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
days.push(d);
}
return days;
}, [cursor]);
const handlePrev = () => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() - 1, 1));
const handleNext = () => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1));
const handleToday = () => setCursor(new Date(today.getFullYear(), today.getMonth(), 1));
const handleAddOnDay = (date: Date) => {
setEditing(null);
setDefaultDate(ymd(date));
setFormOpen(true);
};
const handleAddNew = () => {
setEditing(null);
setDefaultDate(ymd(today));
setFormOpen(true);
};
const handleEdit = (event: EventRow) => {
setEditing(event);
setDefaultDate(undefined);
setFormOpen(true);
};
const handleDelete = () => {
if (!deleting) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deleting.id);
const result = await deleteCalendarEventAction(fd);
if (result.ok) {
toast.success("Etkinlik silindi.");
setDeleting(null);
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
const todayKey = ymd(today);
return (
<Card>
<CardContent className="p-4">
<div className="mb-4 flex flex-col items-center justify-between gap-3 md:flex-row">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" className="size-8" onClick={handlePrev}>
<ChevronLeft className="size-4" />
</Button>
<h2 className="text-lg font-semibold">
{MONTH_NAMES[cursor.getMonth()]} {cursor.getFullYear()}
</h2>
<Button variant="outline" size="icon" className="size-8" onClick={handleNext}>
<ChevronRight className="size-4" />
</Button>
<Button variant="ghost" size="sm" onClick={handleToday}>
Bugün
</Button>
</div>
<Button onClick={handleAddNew}>
<Plus className="size-4" />
Yeni etkinlik
</Button>
</div>
<div className="grid grid-cols-7 gap-px overflow-hidden rounded-md border bg-border">
{WEEKDAYS.map((wd) => (
<div
key={wd}
className="bg-muted/40 text-muted-foreground py-2 text-center text-xs font-medium"
>
{wd}
</div>
))}
{grid.map((d) => {
const inMonth = d.getMonth() === cursor.getMonth();
const key = ymd(d);
const isToday = key === todayKey;
const dayEvents = eventsByDay.get(key) ?? [];
return (
<div
key={key}
className={cn(
"bg-card group relative flex min-h-[110px] flex-col gap-1 p-1.5",
!inMonth && "bg-muted/30",
)}
>
<div className="flex items-center justify-between">
<span
className={cn(
"inline-flex size-6 items-center justify-center rounded-full text-xs",
isToday && "bg-primary text-primary-foreground font-medium",
!inMonth && "text-muted-foreground",
)}
>
{d.getDate()}
</span>
<button
type="button"
onClick={() => handleAddOnDay(d)}
className="text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100"
aria-label="Bu güne etkinlik ekle"
>
<Plus className="size-3.5" />
</button>
</div>
<div className="flex flex-col gap-0.5">
{dayEvents.slice(0, 3).map((e) => (
<button
key={e.id}
type="button"
onClick={() => handleEdit(e)}
className={cn(
"truncate rounded border px-1.5 py-0.5 text-left text-xs",
COLOR_BG[e.color] ?? COLOR_BG[""],
)}
title={e.title}
>
{!e.allDay && (
<span className="opacity-70">
{new Date(e.start).toLocaleTimeString("tr-TR", {
hour: "2-digit",
minute: "2-digit",
})}{" "}
</span>
)}
{e.title}
</button>
))}
{dayEvents.length > 3 && (
<span className="text-muted-foreground px-1 text-xs">
+{dayEvents.length - 3} daha
</span>
)}
</div>
</div>
);
})}
</div>
</CardContent>
<EventFormSheet
open={formOpen}
onOpenChange={(v) => {
setFormOpen(v);
if (!v) setEditing(null);
}}
event={editing}
defaultDate={defaultDate}
customers={customers}
onRequestDelete={(e) => {
setFormOpen(false);
setDeleting(e);
}}
/>
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Etkinliği sil</DialogTitle>
<DialogDescription>
<strong>{deleting?.title}</strong> kalıcı olarak silinecek.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleting(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
@@ -0,0 +1,274 @@
"use client";
import { useActionState, useEffect, useState } from "react";
import { Loader2, Save, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
createCalendarEventAction,
updateCalendarEventAction,
} from "@/lib/appwrite/calendar-actions";
import { initialCalendarState } from "@/lib/appwrite/calendar-types";
import { cn } from "@/lib/utils";
import { COLOR_PRESETS, type Customer, type EventRow } from "./types";
const NONE = "__none__";
type Props = {
open: boolean;
onOpenChange: (v: boolean) => void;
event?: EventRow | null;
defaultDate?: string; // YYYY-MM-DD for new events
customers: Customer[];
onRequestDelete?: (event: EventRow) => void;
};
function isoToInput(iso: string, allDay: boolean): string {
if (!iso) return "";
if (allDay) return iso.slice(0, 10);
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function EventFormSheet({
open,
onOpenChange,
event,
defaultDate,
customers,
onRequestDelete,
}: Props) {
const isEdit = Boolean(event);
const action = isEdit ? updateCalendarEventAction : createCalendarEventAction;
const [state, formAction, isPending] = useActionState(action, initialCalendarState);
const [allDay, setAllDay] = useState<boolean>(event?.allDay ?? false);
useEffect(() => {
setAllDay(event?.allDay ?? false);
}, [event]);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Etkinlik güncellendi." : "Etkinlik eklendi.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
const startDefault =
event?.start
? isoToInput(event.start, allDay)
: defaultDate
? allDay
? defaultDate
: `${defaultDate}T09:00`
: "";
const endDefault =
event?.end
? isoToInput(event.end, allDay)
: defaultDate
? allDay
? defaultDate
: `${defaultDate}T10:00`
: "";
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Etkinliği düzenle" : "Yeni etkinlik"}</SheetTitle>
<SheetDescription>
Tarih, saat ve müşteri bilgileri ile bir takvim girdisi oluşturun.
</SheetDescription>
</SheetHeader>
<form
action={(fd) => {
["customerId", "color"].forEach((k) => {
if (fd.get(k) === NONE) fd.set(k, "");
});
formAction(fd);
}}
className="flex flex-1 flex-col"
>
{isEdit && event && <input type="hidden" name="id" value={event.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div className="grid gap-2">
<Label htmlFor="title">Başlık *</Label>
<Input
id="title"
name="title"
defaultValue={event?.title ?? ""}
placeholder="Örn. Müşteri toplantısı"
required
/>
{state.fieldErrors?.title && (
<p className="text-destructive text-xs">{state.fieldErrors.title}</p>
)}
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="grid gap-0.5">
<Label htmlFor="allDay" className="cursor-pointer">
Tüm gün
</Label>
<p className="text-muted-foreground text-xs">Saat girmeden gün boyu sürecek.</p>
</div>
<Switch
id="allDay"
name="allDay"
checked={allDay}
onCheckedChange={setAllDay}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="start">Başlangıç *</Label>
<Input
id="start"
name="start"
type={allDay ? "date" : "datetime-local"}
defaultValue={startDefault}
required
/>
{state.fieldErrors?.start && (
<p className="text-destructive text-xs">{state.fieldErrors.start}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="end">Bitiş *</Label>
<Input
id="end"
name="end"
type={allDay ? "date" : "datetime-local"}
defaultValue={endDefault}
required
/>
{state.fieldErrors?.end && (
<p className="text-destructive text-xs">{state.fieldErrors.end}</p>
)}
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="customerId">Müşteri (opsiyonel)</Label>
<Select name="customerId" defaultValue={event?.customerId || NONE}>
<SelectTrigger id="customerId">
<SelectValue placeholder="Yok" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Yok</SelectItem>
{customers.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="color">Renk</Label>
<Select name="color" defaultValue={event?.color || NONE}>
<SelectTrigger id="color">
<SelectValue placeholder="Varsayılan" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Varsayılan</SelectItem>
{COLOR_PRESETS.map((c) => (
<SelectItem key={c.value} value={c.value}>
<span className="flex items-center gap-2">
<span className={cn("size-3 rounded-full", c.classes)} />
{c.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Notlar</Label>
<Textarea
id="description"
name="description"
rows={3}
defaultValue={event?.description ?? ""}
placeholder="Açıklama, gündem, vb."
/>
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full items-center justify-between gap-2">
<div>
{isEdit && event && onRequestDelete && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => onRequestDelete(event)}
disabled={isPending}
>
<Trash2 className="size-3.5" />
Sil
</Button>
)}
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Kaydet"}
</>
)}
</Button>
</div>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,32 @@
export type EventRow = {
id: string;
title: string;
description: string;
start: string;
end: string;
allDay: boolean;
customerId: string;
customerName: string;
color: string;
};
export type Customer = { id: string; name: string };
export const COLOR_PRESETS = [
{ value: "blue", label: "Mavi", classes: "bg-blue-500" },
{ value: "green", label: "Yeşil", classes: "bg-emerald-500" },
{ value: "amber", label: "Amber", classes: "bg-amber-500" },
{ value: "red", label: "Kırmızı", classes: "bg-red-500" },
{ value: "violet", label: "Mor", classes: "bg-violet-500" },
{ value: "slate", label: "Gri", classes: "bg-slate-500" },
] as const;
export const COLOR_BG: Record<string, string> = {
blue: "bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30",
green: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
amber: "bg-amber-500/15 text-amber-800 dark:text-amber-300 border-amber-500/30",
red: "bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30",
violet: "bg-violet-500/15 text-violet-700 dark:text-violet-300 border-violet-500/30",
slate: "bg-slate-500/15 text-slate-700 dark:text-slate-300 border-slate-500/30",
"": "bg-primary/10 text-primary border-primary/20",
};
+54
View File
@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { listCalendarEvents } from "@/lib/appwrite/calendar-queries";
import { listCustomers } from "@/lib/appwrite/customer-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { CalendarClient } from "./components/calendar-client";
export const metadata: Metadata = {
title: "İşletmem — Takvim",
};
export default async function CalendarPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const [events, customers] = await Promise.all([
listCalendarEvents(ctx.tenantId),
listCustomers(ctx.tenantId),
]);
const customerMap = new Map(customers.map((c) => [c.$id, c.name]));
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
<h1 className="text-2xl font-bold tracking-tight">Takvim</h1>
<p className="text-muted-foreground text-sm">
Toplantılar, randevular ve önemli tarihler.
</p>
</div>
<CalendarClient
events={events.map((e) => ({
id: e.$id,
title: e.title,
description: e.description ?? "",
start: e.start,
end: e.end,
allDay: Boolean(e.allDay),
customerId: e.customerId ?? "",
customerName: e.customerId ? customerMap.get(e.customerId) ?? "" : "",
color: e.color ?? "",
}))}
customers={customers.map((c) => ({ id: c.$id, name: c.name }))}
/>
</div>
);
}
@@ -0,0 +1,233 @@
"use client"
import {
Phone,
Video,
Info,
Search,
MoreVertical,
Users,
Bell,
BellOff
} from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@/components/ui/tooltip"
import { type Conversation, type User } from "../use-chat"
interface ChatHeaderProps {
conversation: Conversation | null
users: User[]
onToggleMute?: () => void
onToggleInfo?: () => void
}
export function ChatHeader({
conversation,
users,
onToggleMute,
onToggleInfo
}: ChatHeaderProps) {
if (!conversation) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Select a conversation to start chatting</p>
</div>
)
}
const getConversationUsers = () => {
if (conversation.type === "direct") {
return users.filter(user => conversation.participants.includes(user.id))
}
return users.filter(user => conversation.participants.includes(user.id))
}
const conversationUsers = getConversationUsers()
const primaryUser = conversationUsers[0]
const getStatusText = () => {
if (conversation.type === "group") {
const onlineCount = conversationUsers.filter(user => user.status === "online").length
return `${conversation.participants.length} members, ${onlineCount} online`
} else if (primaryUser) {
switch (primaryUser.status) {
case "online":
return "Active now"
case "away":
return "Away"
case "offline":
return `Last seen ${new Date(primaryUser.lastSeen).toLocaleDateString()}`
default:
return ""
}
}
return ""
}
const getStatusColor = () => {
if (conversation.type === "group") return "text-muted-foreground"
switch (primaryUser?.status) {
case "online":
return "text-green-600"
case "away":
return "text-yellow-600"
case "offline":
return "text-muted-foreground"
default:
return "text-muted-foreground"
}
}
return (
<div className="flex items-center justify-between h-full">
{/* Left side - Avatar and info */}
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10 cursor-pointer">
<AvatarImage src={conversation.avatar} alt={conversation.name} />
<AvatarFallback>
{conversation.type === "group" ? (
<Users className="h-5 w-5" />
) : (
conversation.name.split(' ').map(n => n[0]).join('').slice(0, 2)
)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className="font-semibold truncate">{conversation.name}</h2>
{conversation.isMuted && (
<BellOff className="h-4 w-4 text-muted-foreground" />
)}
{conversation.type === "group" && (
<Badge variant="secondary" className="text-xs cursor-pointer">
Group
</Badge>
)}
</div>
<p className={`text-sm ${getStatusColor()}`}>
{getStatusText()}
</p>
</div>
</div>
{/* Right side - Action buttons */}
<div className="flex items-center gap-1">
<TooltipProvider>
{/* Search */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="cursor-pointer">
<Search className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Search in conversation</p>
</TooltipContent>
</Tooltip>
{/* Phone call */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="cursor-pointer">
<Phone className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Voice call</p>
</TooltipContent>
</Tooltip>
{/* Video call */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="cursor-pointer">
<Video className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Video call</p>
</TooltipContent>
</Tooltip>
{/* Info */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onToggleInfo}
className="cursor-pointer"
>
<Info className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Conversation info</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* More options */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="cursor-pointer">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={onToggleMute}
className="cursor-pointer"
>
{conversation.isMuted ? (
<>
<Bell className="h-4 w-4 mr-2" />
Unmute conversation
</>
) : (
<>
<BellOff className="h-4 w-4 mr-2" />
Mute conversation
</>
)}
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<Search className="h-4 w-4 mr-2" />
Search messages
</DropdownMenuItem>
{conversation.type === "group" && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer">
<Users className="h-4 w-4 mr-2" />
Manage members
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer text-destructive">
Delete conversation
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}
@@ -0,0 +1,193 @@
"use client"
import { useEffect, useState } from "react"
import { Menu, X } from "lucide-react"
import { TooltipProvider } from "@/components/ui/tooltip"
import { Button } from "@/components/ui/button"
import { ConversationList } from "./conversation-list"
import { ChatHeader } from "./chat-header"
import { MessageList } from "./message-list"
import { MessageInput } from "./message-input"
import { useChat, type Conversation, type Message, type User } from "../use-chat"
interface ChatProps {
conversations: Conversation[]
messages: Record<string, Message[]>
users: User[]
}
export function Chat({
conversations,
messages,
users,
}: ChatProps) {
const {
selectedConversation,
setSelectedConversation,
setConversations,
setMessages,
setUsers,
addMessage,
toggleMute,
} = useChat()
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
// Close sidebar when clicking outside on mobile
useEffect(() => {
const handleResize = () => {
if (typeof window !== "undefined" ? window.innerWidth : 0 >= 1024) { // lg breakpoint
setIsSidebarOpen(false)
}
}
if (typeof window !== "undefined") {
window.addEventListener('resize', handleResize)
}
return () => {
if (typeof window !== "undefined") {
window.removeEventListener('resize', handleResize)
}
}
}, [])
// Initialize data
useEffect(() => {
setConversations(conversations)
setUsers(users)
// Set messages for all conversations
Object.entries(messages).forEach(([conversationId, conversationMessages]) => {
setMessages(conversationId, conversationMessages)
})
// Auto-select first conversation if none selected
if (!selectedConversation && conversations.length > 0) {
setSelectedConversation(conversations[0].id)
}
}, [conversations, messages, users, selectedConversation, setConversations, setMessages, setUsers, setSelectedConversation])
const currentConversation = conversations.find(conv => conv.id === selectedConversation)
const currentMessages = selectedConversation ? messages[selectedConversation] || [] : []
const handleSendMessage = (content: string) => {
if (!selectedConversation) return
const newMessage = {
id: `msg-${Date.now()}`,
content,
timestamp: new Date().toISOString(),
senderId: "current-user",
type: "text" as const,
isEdited: false,
reactions: [],
replyTo: null,
}
addMessage(selectedConversation, newMessage)
}
const handleToggleMute = () => {
if (selectedConversation) {
toggleMute(selectedConversation)
}
}
return (
<TooltipProvider delayDuration={0}>
<div className="h-full min-h-[600px] max-h-[calc(100vh-200px)] flex rounded-lg border overflow-hidden bg-background">
{/* Mobile Sidebar Overlay */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setIsSidebarOpen(false)}
/>
)}
{/* Conversations Sidebar - Responsive */}
<div className={`
w-100 border-r bg-background flex-shrink-0
${isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
lg:relative lg:block
fixed inset-y-0 left-0 z-50
transition-transform duration-300 ease-in-out
`}>
{/* Sidebar Header with Close Button (Mobile Only) */}
<div className="lg:hidden p-4 border-b flex items-center justify-between bg-background">
<h2 className="text-lg font-semibold">Messages</h2>
<Button
variant="ghost"
size="sm"
onClick={() => setIsSidebarOpen(false)}
className="cursor-pointer"
>
<X className="h-4 w-4" />
</Button>
</div>
<ConversationList
conversations={conversations}
selectedConversation={selectedConversation}
onSelectConversation={(id) => {
setSelectedConversation(id)
setIsSidebarOpen(false) // Close sidebar on mobile after selection
}}
/>
</div>
{/* Chat Panel - Flexible Width */}
<div className="flex-1 flex flex-col min-w-0 bg-background">
{/* Chat Header with Hamburger Menu */}
<div className="flex items-center h-16 px-4 border-b bg-background">
{/* Hamburger Menu Button - Only visible when sidebar is hidden on mobile */}
<Button
variant="ghost"
size="sm"
onClick={() => setIsSidebarOpen(true)}
className="cursor-pointer lg:hidden mr-2"
>
<Menu className="h-4 w-4" />
</Button>
<div className="flex-1">
<ChatHeader
conversation={currentConversation || null}
users={users}
onToggleMute={handleToggleMute}
/>
</div>
</div>
{/* Messages */}
<div className="flex-1 flex flex-col min-h-0">
{selectedConversation ? (
<>
<MessageList
messages={currentMessages}
users={users}
/>
{/* Message Input */}
<MessageInput
onSendMessage={handleSendMessage}
placeholder={`Message ${currentConversation?.name || ""}...`}
/>
</>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<h3 className="text-lg font-semibold mb-2">Welcome to Chat</h3>
<p className="text-muted-foreground">
Select a conversation to start messaging
</p>
</div>
</div>
)}
</div>
</div>
</div>
</TooltipProvider>
)
}
@@ -0,0 +1,221 @@
"use client"
import { format, isToday, isYesterday, isThisWeek, isThisYear } from "date-fns"
import {
Search,
Pin,
VolumeX,
MoreHorizontal,
Users,
Hash
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { useChat, type Conversation } from "../use-chat"
interface ConversationListProps {
conversations: Conversation[]
selectedConversation: string | null
onSelectConversation: (conversationId: string) => void
}
// Enhanced time formatting function
function formatMessageTime(timestamp: string): string {
const date = new Date(timestamp)
if (isToday(date)) {
return format(date, 'h:mm a') // 3:30 PM
} else if (isYesterday(date)) {
return 'Yesterday'
} else if (isThisWeek(date)) {
return format(date, 'EEEE') // Day name
} else if (isThisYear(date)) {
return format(date, 'MMM d') // Jan 15
} else {
return format(date, 'dd/MM/yy') // 15/01/24
}
}
export function ConversationList({
conversations,
selectedConversation,
onSelectConversation
}: ConversationListProps) {
const { searchQuery, setSearchQuery, togglePin, toggleMute } = useChat()
const filteredConversations = conversations.filter((conversation) =>
conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const sortedConversations = filteredConversations.sort((a, b) => {
// Pinned conversations first
if (a.isPinned && !b.isPinned) return -1
if (!a.isPinned && b.isPinned) return 1
// Then by last message timestamp
return new Date(b.lastMessage.timestamp).getTime() - new Date(a.lastMessage.timestamp).getTime()
})
const getOnlineStatus = (conversation: Conversation) => {
if (conversation.type === "direct" && conversation.participants.length === 1) {
// In a real app, you'd check user online status
return Math.random() > 0.5 // Mock online status
}
return false
}
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b flex-shrink-0">
<h2 className="text-lg font-semibold">Messages</h2>
</div>
{/* Search */}
<div className="p-4 border-b flex-shrink-0">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search conversations..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 cursor-text"
/>
</div>
</div>
{/* Conversations */}
<ScrollArea className="flex-1">
<div className="p-2">
{sortedConversations.map((conversation) => (
<div
key={conversation.id}
className={cn(
"flex items-center gap-3 p-3 rounded-lg cursor-pointer relative group overflow-hidden hover:bg-accent/50 transition-colors",
selectedConversation === conversation.id
? "bg-accent text-accent-foreground"
: ""
)}
onClick={() => onSelectConversation(conversation.id)}
>
{/* Avatar with online indicator */}
<div className="relative flex-shrink-0">
<Avatar className={cn(
"h-12 w-12",
selectedConversation === conversation.id && "ring-2 ring-background"
)}>
<AvatarImage src={conversation.avatar} alt={conversation.name} />
<AvatarFallback className="text-sm">
{conversation.type === "group" ? (
<Users className="h-5 w-5" />
) : (
conversation.name.split(' ').map(n => n[0]).join('').slice(0, 2)
)}
</AvatarFallback>
</Avatar>
{/* Online indicator for direct messages */}
{conversation.type === "direct" && getOnlineStatus(conversation) && (
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-green-500 border-2 border-background rounded-full" />
)}
{/* Group indicator */}
{conversation.type === "group" && (
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-blue-500 border-2 border-background rounded-full flex items-center justify-center">
<Hash className="h-2 w-2 text-white" />
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center justify-between mb-1 min-w-0">
<div className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
<h3 className="font-medium truncate min-w-0 max-w-[180px]">{conversation.name}</h3>
{conversation.isPinned && (
<Pin className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
{conversation.isMuted && (
<VolumeX className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
</div>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2 whitespace-nowrap">
{formatMessageTime(conversation.lastMessage.timestamp)}
</span>
</div>
<div className="flex items-center justify-between gap-2 min-w-0">
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0 max-w-[200px]">
{conversation.lastMessage.content}
</p>
{/* Unread count */}
{conversation.unreadCount > 0 && (
<Badge variant="default" className="ml-2 min-w-[20px] h-5 text-xs cursor-pointer flex-shrink-0">
{conversation.unreadCount > 99 ? "99+" : conversation.unreadCount}
</Badge>
)}
</div>
</div>
{/* Actions menu */}
<div className="opacity-0 group-hover:opacity-100 ml-2 flex-shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
togglePin(conversation.id)
}}
className="cursor-pointer"
>
<Pin className="h-4 w-4 mr-2" />
{conversation.isPinned ? "Unpin" : "Pin"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
toggleMute(conversation.id)
}}
className="cursor-pointer"
>
<VolumeX className="h-4 w-4 mr-2" />
{conversation.isMuted ? "Unmute" : "Mute"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer text-destructive">
Delete conversation
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)
}
@@ -0,0 +1,208 @@
"use client"
import { format, isToday, isYesterday, isThisWeek, isThisYear } from "date-fns"
import {
Search,
Pin,
VolumeX,
MoreVertical,
Users,
Hash,
Settings,
UserPlus,
Filter
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { useChat, type Conversation } from "../use-chat"
interface ConversationListProps {
conversations: Conversation[]
selectedConversation: string | null
onSelectConversation: (conversationId: string) => void
}
// Enhanced time formatting function
function formatMessageTime(timestamp: string): string {
const date = new Date(timestamp)
if (isToday(date)) {
return format(date, 'h:mm a') // 3:30 PM
} else if (isYesterday(date)) {
return 'Yesterday'
} else if (isThisWeek(date)) {
return format(date, 'EEEE') // Day name
} else if (isThisYear(date)) {
return format(date, 'MMM d') // Jan 15
} else {
return format(date, 'dd/MM/yy') // 15/01/24
}
}
export function ConversationList({
conversations,
selectedConversation,
onSelectConversation
}: ConversationListProps) {
const { searchQuery, setSearchQuery } = useChat()
const filteredConversations = conversations.filter((conversation) =>
conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const sortedConversations = filteredConversations.sort((a, b) => {
// Pinned conversations first
if (a.isPinned && !b.isPinned) return -1
if (!a.isPinned && b.isPinned) return 1
// Then by last message timestamp
return new Date(b.lastMessage.timestamp).getTime() - new Date(a.lastMessage.timestamp).getTime()
})
const getOnlineStatus = (conversation: Conversation) => {
if (conversation.type === "direct" && conversation.participants.length === 1) {
// In a real app, you'd check user online status
return Math.random() > 0.5 // Mock online status
}
return false
}
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header - Hidden on mobile (handled by parent) */}
<div className="hidden lg:flex items-center justify-between h-16 px-4 border-b flex-shrink-0">
<h2 className="text-lg font-semibold">Messages</h2>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 cursor-pointer"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="cursor-pointer">
<UserPlus className="h-4 w-4 mr-2" />
New Chat
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<Filter className="h-4 w-4 mr-2" />
Filter Messages
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer">
<Settings className="h-4 w-4 mr-2" />
Chat Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Search */}
<div className="px-4 py-3 border-b flex-shrink-0">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search conversations..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 cursor-text"
/>
</div>
</div>
{/* Conversations */}
<ScrollArea className="flex-1">
<div className="p-2">
{sortedConversations.map((conversation) => (
<div
key={conversation.id}
className={cn(
"flex items-center gap-3 p-3 rounded-lg cursor-pointer relative overflow-hidden hover:bg-accent/50 transition-colors",
selectedConversation === conversation.id
? "bg-accent text-accent-foreground"
: ""
)}
onClick={() => onSelectConversation(conversation.id)}
>
{/* Avatar with online indicator */}
<div className="relative flex-shrink-0">
<Avatar className={cn(
"h-12 w-12",
selectedConversation === conversation.id && "ring-2 ring-background"
)}>
<AvatarImage src={conversation.avatar} alt={conversation.name} />
<AvatarFallback className="text-sm">
{conversation.type === "group" ? (
<Users className="h-5 w-5" />
) : (
conversation.name.split(' ').map(n => n[0]).join('').slice(0, 2)
)}
</AvatarFallback>
</Avatar>
{/* Online indicator for direct messages */}
{conversation.type === "direct" && getOnlineStatus(conversation) && (
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-green-500 border-2 border-background rounded-full" />
)}
{/* Group indicator */}
{conversation.type === "group" && (
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-blue-500 border-2 border-background rounded-full flex items-center justify-center">
<Hash className="h-2 w-2 text-white" />
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center justify-between mb-1 min-w-0">
<div className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden pr-2">
<h3 className="font-medium truncate min-w-0 max-w-[160px] lg:max-w-[180px]">{conversation.name}</h3>
{conversation.isPinned && (
<Pin className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
{conversation.isMuted && (
<VolumeX className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
</div>
<span className="text-xs text-muted-foreground flex-shrink-0 whitespace-nowrap">
{formatMessageTime(conversation.lastMessage.timestamp)}
</span>
</div>
<div className="flex items-center justify-between gap-2 min-w-0">
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0 max-w-[180px] lg:max-w-[200px] pr-2">
{conversation.lastMessage.content}
</p>
{/* Unread count */}
{conversation.unreadCount > 0 && (
<Badge variant="default" className="min-w-[20px] h-5 text-xs cursor-pointer flex-shrink-0">
{conversation.unreadCount > 99 ? "99+" : conversation.unreadCount}
</Badge>
)}
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)
}
@@ -0,0 +1,225 @@
"use client"
import { useState, useRef } from "react"
import {
Send,
Paperclip,
Smile,
Image as ImageIcon,
FileText,
Mic,
MoreHorizontal
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@/components/ui/tooltip"
interface MessageInputProps {
onSendMessage: (content: string) => void
disabled?: boolean
placeholder?: string
}
export function MessageInput({
onSendMessage,
disabled = false,
placeholder = "Type a message..."
}: MessageInputProps) {
const [message, setMessage] = useState("")
const [isTyping, setIsTyping] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleSendMessage = () => {
const trimmedMessage = message.trim()
if (trimmedMessage && !disabled) {
onSendMessage(trimmedMessage)
setMessage("")
setIsTyping(false)
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = "auto"
}
}
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value
setMessage(value)
// Auto-resize textarea
if (textareaRef.current) {
textareaRef.current.style.height = "auto"
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`
}
// Handle typing indicator
if (value.trim() && !isTyping) {
setIsTyping(true)
} else if (!value.trim() && isTyping) {
setIsTyping(false)
}
}
const handleFileUpload = (type: "image" | "file") => {
// In a real app, this would open a file picker
console.log(`Upload ${type}`)
}
return (
<div className="border-t p-4">
<div className="flex items-end gap-2">
{/* Attachment button */}
<TooltipProvider>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={disabled}
className="cursor-pointer disabled:cursor-not-allowed"
>
<Paperclip className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Attach file</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="start">
<DropdownMenuItem
onClick={() => handleFileUpload("image")}
className="cursor-pointer"
>
<ImageIcon className="h-4 w-4 mr-2" />
Photo or video
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleFileUpload("file")}
className="cursor-pointer"
>
<FileText className="h-4 w-4 mr-2" />
Document
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TooltipProvider>
{/* Message input */}
<div className="flex-1 relative">
<Textarea
ref={textareaRef}
placeholder={placeholder}
value={message}
onChange={handleTextareaChange}
onKeyDown={handleKeyPress}
disabled={disabled}
className={cn(
"min-h-[40px] max-h-[120px] resize-none cursor-text disabled:cursor-not-allowed",
"pr-20" // Space for emoji and more buttons
)}
rows={1}
/>
{/* Input action buttons */}
<div className="absolute right-2 bottom-2 flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={disabled}
className="h-6 w-6 p-0 cursor-pointer disabled:cursor-not-allowed"
>
<Smile className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Add emoji</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={disabled}
className="h-6 w-6 p-0 cursor-pointer disabled:cursor-not-allowed"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>More options</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Voice message or send button */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{message.trim() ? (
<Button
onClick={handleSendMessage}
disabled={disabled}
className="cursor-pointer disabled:cursor-not-allowed"
>
<Send className="h-4 w-4" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
disabled={disabled}
className="cursor-pointer disabled:cursor-not-allowed"
>
<Mic className="h-4 w-4" />
</Button>
)}
</TooltipTrigger>
<TooltipContent>
<p>{message.trim() ? "Send message" : "Voice message"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{/* Typing indicator */}
{isTyping && (
<div className="text-xs text-muted-foreground mt-2">
You are typing...
</div>
)}
</div>
)
}
@@ -0,0 +1,295 @@
"use client"
import { useEffect, useRef } from "react"
import { format, isToday, isYesterday } from "date-fns"
import { CheckCheck, MoreHorizontal, Reply, Copy, Trash2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { type Message, type User } from "../use-chat"
interface MessageListProps {
messages: Message[]
users: User[]
currentUserId?: string
}
export function MessageList({ messages, users, currentUserId = "current-user" }: MessageListProps) {
const scrollAreaRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const previousMessageCountRef = useRef(0)
const isInitialLoadRef = useRef(true)
const previousConversationRef = useRef<string | null>(null)
// Reset scroll behavior when switching conversations
useEffect(() => {
const currentConversationId = messages.length > 0 ? messages[0]?.id?.split('-')[0] : null
if (currentConversationId !== previousConversationRef.current) {
isInitialLoadRef.current = true
previousConversationRef.current = currentConversationId
}
}, [messages])
// Auto-scroll to bottom only when new messages are added (not on initial load)
useEffect(() => {
// Skip auto-scroll on initial load
if (isInitialLoadRef.current) {
isInitialLoadRef.current = false
previousMessageCountRef.current = messages.length
return
}
// Only auto-scroll if new messages were added
if (messages.length > previousMessageCountRef.current && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: "smooth" })
}
previousMessageCountRef.current = messages.length
}, [messages])
const getUserById = (userId: string) => {
if (userId === currentUserId) {
return {
id: currentUserId,
name: "You",
avatar: "https://notion-avatars.netlify.app/api/avatar/?preset=male-7",
status: "online" as const,
email: "you@example.com",
lastSeen: new Date().toISOString(),
role: "Developer",
department: "Engineering"
}
}
return users.find(user => user.id === userId)
}
const formatMessageTime = (timestamp: string) => {
const date = new Date(timestamp)
if (isToday(date)) {
return format(date, "HH:mm")
} else if (isYesterday(date)) {
return `Yesterday ${format(date, "HH:mm")}`
} else {
return format(date, "MMM d, HH:mm")
}
}
const shouldShowAvatar = (message: Message, index: number) => {
if (message.senderId === currentUserId) return false
if (index === 0) return true
const prevMessage = messages[index - 1]
return prevMessage.senderId !== message.senderId
}
const shouldShowName = (message: Message, index: number) => {
if (message.senderId === currentUserId) return false
if (index === 0) return true
const prevMessage = messages[index - 1]
return prevMessage.senderId !== message.senderId
}
const isConsecutiveMessage = (message: Message, index: number) => {
if (index === 0) return false
const prevMessage = messages[index - 1]
const timeDiff = new Date(message.timestamp).getTime() - new Date(prevMessage.timestamp).getTime()
return prevMessage.senderId === message.senderId && timeDiff < 5 * 60 * 1000 // 5 minutes
}
const groupMessagesByDay = (messages: Message[]) => {
const groups: { date: string; messages: Message[] }[] = []
messages.forEach((message) => {
const messageDate = format(new Date(message.timestamp), "yyyy-MM-dd")
const lastGroup = groups[groups.length - 1]
if (lastGroup && lastGroup.date === messageDate) {
lastGroup.messages.push(message)
} else {
groups.push({
date: messageDate,
messages: [message]
})
}
})
return groups
}
const formatDateHeader = (dateString: string) => {
const date = new Date(dateString)
if (isToday(date)) {
return "Today"
} else if (isYesterday(date)) {
return "Yesterday"
} else {
return format(date, "EEEE, MMMM d")
}
}
const messageGroups = groupMessagesByDay(messages)
return (
<ScrollArea className="flex-1 px-4" ref={scrollAreaRef}>
<div className="space-y-4 py-4">
{messageGroups.map((group) => (
<div key={group.date}>
{/* Date separator */}
<div className="flex items-center justify-center py-2">
<div className="text-xs text-muted-foreground bg-background px-3 py-1 rounded-full border">
{formatDateHeader(group.date)}
</div>
</div>
{/* Messages for this day */}
<div className="space-y-1">
{group.messages.map((message, messageIndex) => {
const user = getUserById(message.senderId)
const isOwnMessage = message.senderId === currentUserId
const showAvatar = shouldShowAvatar(message, messageIndex)
const showName = shouldShowName(message, messageIndex)
const isConsecutive = isConsecutiveMessage(message, messageIndex)
return (
<div
key={message.id}
className={cn(
"flex gap-3 group",
isOwnMessage && "flex-row-reverse",
isConsecutive && !isOwnMessage && "ml-12"
)}
>
{/* Avatar */}
{!isOwnMessage && (
<div className="w-8">
{showAvatar && user && (
<Avatar className="h-8 w-8 cursor-pointer">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="text-xs">
{user.name.split(' ').map(n => n[0]).join('').slice(0, 2)}
</AvatarFallback>
</Avatar>
)}
</div>
)}
{/* Message content */}
<div className={cn("flex-1 max-w-[70%]", isOwnMessage && "flex flex-col items-end")}>
{/* Sender name for group messages */}
{showName && user && !isOwnMessage && (
<div className="text-sm font-medium text-foreground mb-1">
{user.name}
</div>
)}
{/* Message bubble */}
<div className="relative group/message">
<div
className={cn(
"rounded-lg px-3 py-2 text-sm break-words",
isOwnMessage
? "bg-primary text-primary-foreground"
: "bg-muted",
isConsecutive && "mt-1"
)}
>
<p>{message.content}</p>
{/* Message reactions */}
{message.reactions.length > 0 && (
<div className="flex gap-1 mt-2">
{message.reactions.map((reaction, idx) => (
<div
key={idx}
className={cn(
"inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs border cursor-pointer",
"bg-background/90 backdrop-blur-sm shadow-sm"
)}
>
<span>{reaction.emoji}</span>
<span className="text-muted-foreground">{reaction.count}</span>
</div>
))}
</div>
)}
{/* Timestamp and status */}
<div className={cn(
"flex items-center gap-1 mt-1 text-xs",
isOwnMessage
? "text-primary-foreground/70 justify-end"
: "text-muted-foreground"
)}>
<span>{formatMessageTime(message.timestamp)}</span>
{message.isEdited && (
<span className="italic">(edited)</span>
)}
{isOwnMessage && (
<div className="flex">
{/* Message status indicators */}
<CheckCheck className="h-3 w-3" />
</div>
)}
</div>
</div>
{/* Message actions */}
<div className="absolute top-0 right-0 opacity-0 group-hover/message:opacity-100">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 cursor-pointer"
>
<MoreHorizontal className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="cursor-pointer">
<Reply className="h-4 w-4 mr-2" />
Reply
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<Copy className="h-4 w-4 mr-2" />
Copy
</DropdownMenuItem>
{isOwnMessage && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
))}
{/* Scroll anchor */}
<div ref={bottomRef} />
</div>
</ScrollArea>
)
}
@@ -0,0 +1,96 @@
[
{
"id": "conv-1",
"type": "direct",
"participants": ["1"],
"name": "Sarah Mitchell",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-7",
"lastMessage": {
"id": "msg-1-4",
"content": "Thanks for the quick update! The dashboard looks amazing 🎉",
"timestamp": "2025-08-11T15:30:00Z",
"senderId": "1"
},
"unreadCount": 2,
"isPinned": true,
"isMuted": false
},
{
"id": "conv-2",
"type": "group",
"participants": ["2", "3", "5"],
"name": "Project Alpha",
"lastMessage": {
"id": "msg-2-8",
"content": "David: Marketing campaign is scheduled for next week",
"timestamp": "2025-08-11T08:15:00Z",
"senderId": "2"
},
"unreadCount": 0,
"isPinned": false,
"isMuted": false
},
{
"id": "conv-3",
"type": "group",
"participants": ["2", "3", "5"],
"name": "Frontend Team",
"lastMessage": {
"id": "msg-3-6",
"content": "Alex: The new component library is ready for testing",
"timestamp": "2025-08-11T23:45:00Z",
"senderId": "3"
},
"unreadCount": 1,
"isPinned": false,
"isMuted": false
},
{
"id": "conv-4",
"type": "direct",
"participants": ["3"],
"name": "Emily Rodriguez",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-2",
"lastMessage": {
"id": "msg-4-3",
"content": "Let's review the wireframes together tomorrow",
"timestamp": "2025-08-10T16:30:00Z",
"senderId": "3"
},
"unreadCount": 1,
"isPinned": false,
"isMuted": false
},
{
"id": "conv-5",
"type": "direct",
"participants": ["5"],
"name": "Lisa Chen",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-4",
"lastMessage": {
"id": "msg-5-3",
"content": "Found a few edge cases in the new feature",
"timestamp": "2025-08-06T14:20:00Z",
"senderId": "5"
},
"unreadCount": 0,
"isPinned": false,
"isMuted": true
},
{
"id": "conv-6",
"type": "direct",
"participants": ["2"],
"name": "Alex Thompson",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=male-1",
"lastMessage": {
"id": "msg-6-3",
"content": "Code review completed, looks good to merge! 👍",
"timestamp": "2025-01-15T17:45:00Z",
"senderId": "2"
},
"unreadCount": 0,
"isPinned": false,
"isMuted": false
}
]
+224
View File
@@ -0,0 +1,224 @@
{
"conv-1": [
{
"id": "msg-1-1",
"content": "Hey! How's the new dashboard coming along?",
"timestamp": "2024-01-15T10:15:00Z",
"senderId": "1",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-1-2",
"content": "It's going great! We've implemented the new design system and it looks fantastic.",
"timestamp": "2024-01-15T10:17:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [{"emoji": "👍", "users": ["1"], "count": 1}],
"replyTo": null
},
{
"id": "msg-1-3",
"content": "That's awesome! Can you share a preview?",
"timestamp": "2024-01-15T10:18:00Z",
"senderId": "1",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-1-4",
"content": "Thanks for the quick update! The dashboard looks amazing 🎉",
"timestamp": "2024-01-15T10:30:00Z",
"senderId": "1",
"type": "text",
"isEdited": false,
"reactions": [{"emoji": "❤️", "users": ["current-user"], "count": 1}],
"replyTo": null
}
],
"conv-2": [
{
"id": "msg-2-1",
"content": "Hey team! The component library update is ready",
"timestamp": "2024-01-15T09:00:00Z",
"senderId": "2",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-2-2",
"content": "Awesome work Alex! 🚀",
"timestamp": "2024-01-15T09:05:00Z",
"senderId": "3",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-2-3",
"content": "I've tested the new Button and Input components, they work perfectly",
"timestamp": "2024-01-15T09:10:00Z",
"senderId": "5",
"type": "text",
"isEdited": false,
"reactions": [{"emoji": "✅", "users": ["2", "3"], "count": 2}],
"replyTo": null
},
{
"id": "msg-2-4",
"content": "Great! I'll start integrating them into the main app",
"timestamp": "2024-01-15T09:15:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
}
],
"conv-3": [
{
"id": "msg-3-1",
"content": "Hi! I've completed the wireframes for the new user onboarding flow",
"timestamp": "2024-01-15T09:30:00Z",
"senderId": "3",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-3-2",
"content": "That's fantastic Emily! When can we review them?",
"timestamp": "2024-01-15T09:32:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-3-3",
"content": "How about tomorrow at 2 PM? I'll share my screen and walk through the designs",
"timestamp": "2024-01-15T09:35:00Z",
"senderId": "3",
"type": "text",
"isEdited": false,
"reactions": [{"emoji": "👍", "users": ["current-user"], "count": 1}],
"replyTo": null
},
{
"id": "msg-3-4",
"content": "Perfect! Looking forward to it",
"timestamp": "2024-01-15T09:40:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
}
],
"conv-4": [
{
"id": "msg-4-1",
"content": "Hi! I've been working on the wireframes for the new feature",
"timestamp": "2025-08-10T14:15:00Z",
"senderId": "3",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-4-2",
"content": "That's great! I'd love to take a look at them",
"timestamp": "2025-08-10T14:18:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-4-3",
"content": "Let's review the wireframes together tomorrow",
"timestamp": "2025-08-10T16:30:00Z",
"senderId": "3",
"type": "text",
"isEdited": false,
"reactions": [{"emoji": "👍", "users": ["current-user"], "count": 1}],
"replyTo": null
}
],
"conv-5": [
{
"id": "msg-5-1",
"content": "I've been testing the new feature and it looks good overall",
"timestamp": "2025-08-06T13:45:00Z",
"senderId": "5",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-5-2",
"content": "Thanks for testing it! Any issues you found?",
"timestamp": "2025-08-06T14:10:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-5-3",
"content": "Found a few edge cases in the new feature",
"timestamp": "2025-08-06T14:20:00Z",
"senderId": "5",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
}
],
"conv-6": [
{
"id": "msg-6-1",
"content": "Hey! I've finished the code review for the latest PR",
"timestamp": "2025-01-15T16:30:00Z",
"senderId": "2",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-6-2",
"content": "Thanks for the quick review! Any feedback?",
"timestamp": "2025-01-15T17:15:00Z",
"senderId": "current-user",
"type": "text",
"isEdited": false,
"reactions": [],
"replyTo": null
},
{
"id": "msg-6-3",
"content": "Code review completed, looks good to merge! 👍",
"timestamp": "2025-01-15T17:45:00Z",
"senderId": "2",
"type": "text",
"isEdited": false,
"reactions": [{"emoji": "🎉", "users": ["current-user"], "count": 1}],
"replyTo": null
}
]
}
+52
View File
@@ -0,0 +1,52 @@
[
{
"id": "1",
"name": "Sarah Mitchell",
"email": "sarah.mitchell@example.com",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-7",
"status": "online",
"lastSeen": "2024-01-15T10:30:00Z",
"role": "Project Manager",
"department": "Product"
},
{
"id": "2",
"name": "Alex Thompson",
"email": "alex.thompson@example.com",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=male-1",
"status": "away",
"lastSeen": "2024-01-15T09:45:00Z",
"role": "Senior Developer",
"department": "Engineering"
},
{
"id": "3",
"name": "Emily Rodriguez",
"email": "emily.rodriguez@example.com",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-2",
"status": "online",
"lastSeen": "2024-01-15T10:25:00Z",
"role": "UX Designer",
"department": "Design"
},
{
"id": "4",
"name": "David Kim",
"email": "david.kim@example.com",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=male-5",
"status": "offline",
"lastSeen": "2024-01-14T18:30:00Z",
"role": "Marketing Lead",
"department": "Marketing"
},
{
"id": "5",
"name": "Lisa Chen",
"email": "lisa.chen@example.com",
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-4",
"status": "online",
"lastSeen": "2024-01-15T10:20:00Z",
"role": "QA Engineer",
"department": "Engineering"
}
]
+53
View File
@@ -0,0 +1,53 @@
"use client"
import { useEffect, useState } from "react"
import { Chat } from "./components/chat"
import { type Conversation, type Message, type User } from "./use-chat"
// Import static data
import conversationsData from "./data/conversations.json"
import messagesData from "./data/messages.json"
import usersData from "./data/users.json"
export default function ChatPage() {
const [conversations, setConversations] = useState<Conversation[]>([])
const [messages, setMessages] = useState<Record<string, Message[]>>({})
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const loadData = async () => {
try {
// In a real app, these would be API calls
setConversations(conversationsData as Conversation[])
setMessages(messagesData as Record<string, Message[]>)
setUsers(usersData as User[])
} catch (error) {
console.error("Failed to load chat data:", error)
} finally {
setLoading(false)
}
}
loadData()
}, [])
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-muted-foreground">Loading chat...</div>
</div>
)
}
return (
<div className="px-4 md:px-6">
<Chat
conversations={conversations}
messages={messages}
users={users}
/>
</div>
)
}
+149
View File
@@ -0,0 +1,149 @@
"use client"
import { create } from "zustand"
export interface User {
id: string
name: string
email: string
avatar: string
status: "online" | "away" | "offline"
lastSeen: string
role: string
department: string
}
export interface Message {
id: string
content: string
timestamp: string
senderId: string
type: "text" | "image" | "file"
isEdited: boolean
reactions: Array<{
emoji: string
users: string[]
count: number
}>
replyTo: string | null
}
export interface Conversation {
id: string
type: "direct" | "group"
participants: string[]
name: string
avatar: string
lastMessage: {
id: string
content: string
timestamp: string
senderId: string
}
unreadCount: number
isPinned: boolean
isMuted: boolean
}
interface ChatState {
conversations: Conversation[]
messages: Record<string, Message[]>
users: User[]
selectedConversation: string | null
searchQuery: string
isTyping: Record<string, boolean>
onlineUsers: string[]
}
interface ChatActions {
setConversations: (conversations: Conversation[]) => void
setMessages: (conversationId: string, messages: Message[]) => void
setUsers: (users: User[]) => void
setSelectedConversation: (conversationId: string | null) => void
setSearchQuery: (query: string) => void
addMessage: (conversationId: string, message: Message) => void
markAsRead: (conversationId: string) => void
togglePin: (conversationId: string) => void
toggleMute: (conversationId: string) => void
setTyping: (conversationId: string, isTyping: boolean) => void
setOnlineUsers: (userIds: string[]) => void
}
export const useChat = create<ChatState & ChatActions>((set, get) => ({
// State
conversations: [],
messages: {},
users: [],
selectedConversation: null,
searchQuery: "",
isTyping: {},
onlineUsers: [],
// Actions
setConversations: (conversations) => set({ conversations }),
setMessages: (conversationId, messages) =>
set((state) => ({
messages: { ...state.messages, [conversationId]: messages }
})),
setUsers: (users) => set({ users }),
setSelectedConversation: (conversationId) => {
set({ selectedConversation: conversationId })
if (conversationId) {
get().markAsRead(conversationId)
}
},
setSearchQuery: (query) => set({ searchQuery: query }),
addMessage: (conversationId, message) =>
set((state) => ({
messages: {
...state.messages,
[conversationId]: [...(state.messages[conversationId] || []), message]
},
conversations: state.conversations.map((conv) =>
conv.id === conversationId
? {
...conv,
lastMessage: {
id: message.id,
content: message.content,
timestamp: message.timestamp,
senderId: message.senderId
}
}
: conv
)
})),
markAsRead: (conversationId) =>
set((state) => ({
conversations: state.conversations.map((conv) =>
conv.id === conversationId ? { ...conv, unreadCount: 0 } : conv
)
})),
togglePin: (conversationId) =>
set((state) => ({
conversations: state.conversations.map((conv) =>
conv.id === conversationId ? { ...conv, isPinned: !conv.isPinned } : conv
)
})),
toggleMute: (conversationId) =>
set((state) => ({
conversations: state.conversations.map((conv) =>
conv.id === conversationId ? { ...conv, isMuted: !conv.isMuted } : conv
)
})),
setTyping: (conversationId, isTyping) =>
set((state) => ({
isTyping: { ...state.isTyping, [conversationId]: isTyping }
})),
setOnlineUsers: (userIds) => set({ onlineUsers: userIds }),
}))
@@ -0,0 +1,196 @@
"use client";
import { useActionState, useEffect, useState } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
import {
createCustomerAction,
updateCustomerAction,
} from "@/lib/appwrite/customer-actions";
import { initialCustomerState } from "@/lib/appwrite/customer-types";
import type { CustomerRow } from "./types";
type Props = {
open: boolean;
onOpenChange: (v: boolean) => void;
customer?: CustomerRow | null;
};
export function CustomerFormSheet({ open, onOpenChange, customer }: Props) {
const isEdit = Boolean(customer);
const action = isEdit ? updateCustomerAction : createCustomerAction;
const [state, formAction, isPending] = useActionState(action, initialCustomerState);
const [planLimitOpen, setPlanLimitOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Müşteri güncellendi." : "Müşteri eklendi.");
onOpenChange(false);
} else if (state.code === "PLAN_LIMIT_EXCEEDED") {
setPlanLimitOpen(true);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Müşteriyi düzenle" : "Yeni müşteri"}</SheetTitle>
<SheetDescription>
{isEdit
? "Müşteri bilgilerini güncelleyin."
: "Yeni bir müşteri ekleyin. * işaretli alanlar zorunludur."}
</SheetDescription>
</SheetHeader>
<form action={formAction} className="flex flex-1 flex-col">
{isEdit && customer && <input type="hidden" name="id" value={customer.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div className="grid gap-2">
<Label htmlFor="name">Ad / Şirket adı *</Label>
<Input
id="name"
name="name"
defaultValue={customer?.name ?? ""}
placeholder="Örn. Acme Yazılım Ltd."
required
/>
{state.fieldErrors?.name && (
<p className="text-destructive text-xs">{state.fieldErrors.name}</p>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
defaultValue={customer?.email ?? ""}
placeholder="info@acme.com"
/>
{state.fieldErrors?.email && (
<p className="text-destructive text-xs">{state.fieldErrors.email}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="phone">Telefon</Label>
<Input
id="phone"
name="phone"
type="tel"
defaultValue={customer?.phone ?? ""}
placeholder="+90 555 123 45 67"
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="taxId">Vergi numarası</Label>
<Input
id="taxId"
name="taxId"
defaultValue={customer?.taxId ?? ""}
placeholder="1234567890"
inputMode="numeric"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="status">Durum</Label>
<Select name="status" defaultValue={customer?.status ?? "active"}>
<SelectTrigger id="status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Aktif</SelectItem>
<SelectItem value="passive">Pasif</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="address">Adres</Label>
<Textarea
id="address"
name="address"
rows={2}
defaultValue={customer?.address ?? ""}
placeholder="Açık adres"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notlar</Label>
<Textarea
id="notes"
name="notes"
rows={4}
defaultValue={customer?.notes ?? ""}
placeholder="Müşteriye özel notlar"
/>
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Kaydet"}
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
<PlanLimitDialog
open={planLimitOpen}
onOpenChange={setPlanLimitOpen}
message={state.error}
/>
</Sheet>
);
}
@@ -0,0 +1,314 @@
"use client";
import { useMemo, useState } from "react";
import {
type ColumnDef,
type SortingState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
ArrowUpDown,
ChevronLeft,
ChevronRight,
MoreHorizontal,
Pencil,
Plus,
Search,
Trash2,
UserPlus,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CustomerFormSheet } from "./customer-form-sheet";
import { DeleteCustomerDialog } from "./delete-customer-dialog";
import type { CustomerRow } from "./types";
type Props = { customers: CustomerRow[] };
export function CustomersClient({ customers }: Props) {
const [globalFilter, setGlobalFilter] = useState("");
const [sorting, setSorting] = useState<SortingState>([]);
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<CustomerRow | null>(null);
const [deleting, setDeleting] = useState<CustomerRow | null>(null);
const columns = useMemo<ColumnDef<CustomerRow>[]>(
() => [
{
accessorKey: "name",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
İsim
<ArrowUpDown className="ml-2 size-3.5" />
</Button>
),
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) =>
row.original.email ? (
<a
href={`mailto:${row.original.email}`}
className="text-muted-foreground hover:text-foreground hover:underline"
>
{row.original.email}
</a>
) : (
<span className="text-muted-foreground"></span>
),
},
{
accessorKey: "phone",
header: "Telefon",
cell: ({ row }) =>
row.original.phone ? (
<a
href={`tel:${row.original.phone}`}
className="text-muted-foreground hover:text-foreground hover:underline"
>
{row.original.phone}
</a>
) : (
<span className="text-muted-foreground"></span>
),
},
{
accessorKey: "status",
header: "Durum",
cell: ({ row }) => (
<Badge variant={row.original.status === "active" ? "default" : "secondary"}>
{row.original.status === "active" ? "Aktif" : "Pasif"}
</Badge>
),
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Eklendi
<ArrowUpDown className="ml-2 size-3.5" />
</Button>
),
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{new Date(row.original.createdAt).toLocaleDateString("tr-TR", {
day: "2-digit",
month: "short",
year: "numeric",
})}
</span>
),
},
{
id: "actions",
cell: ({ row }) => (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setEditing(row.original);
setFormOpen(true);
}}
>
<Pencil className="size-3.5" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleting(row.original)}
>
<Trash2 className="size-3.5" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
],
[],
);
const table = useReactTable({
data: customers,
columns,
state: { globalFilter, sorting },
onGlobalFilterChange: setGlobalFilter,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 20 } },
globalFilterFn: (row, _id, filterValue) => {
const v = String(filterValue).toLowerCase();
return [row.original.name, row.original.email, row.original.phone, row.original.taxId]
.join(" ")
.toLowerCase()
.includes(v);
},
});
return (
<Card>
<CardContent className="p-0">
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
<div className="relative md:max-w-xs md:flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="İsim, email, telefon, vergi no..."
className="pl-9"
/>
</div>
<Button
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
>
<Plus className="size-4" />
Yeni müşteri
</Button>
</div>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-32 text-center">
<div className="text-muted-foreground flex flex-col items-center gap-2">
<UserPlus className="size-6" />
<p className="text-sm">Henüz müşteri eklenmemiş.</p>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
>
<Plus className="size-3.5" />
İlk müşteriyi ekle
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="flex items-center justify-between border-t px-4 py-3">
<p className="text-muted-foreground text-sm">
Toplam {table.getFilteredRowModel().rows.length} müşteri
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-muted-foreground text-sm">
Sayfa {table.getState().pagination.pageIndex + 1} /{" "}
{Math.max(table.getPageCount(), 1)}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</CardContent>
<CustomerFormSheet
open={formOpen}
onOpenChange={(v) => {
setFormOpen(v);
if (!v) setEditing(null);
}}
customer={editing}
/>
<DeleteCustomerDialog
open={Boolean(deleting)}
onOpenChange={(v) => !v && setDeleting(null)}
id={deleting?.id ?? null}
name={deleting?.name ?? ""}
/>
</Card>
);
}
@@ -0,0 +1,76 @@
"use client";
import { useTransition } from "react";
import { Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { deleteCustomerAction } from "@/lib/appwrite/customer-actions";
export function DeleteCustomerDialog({
open,
onOpenChange,
id,
name,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
id: string | null;
name: string;
}) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (!id) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", id);
const result = await deleteCustomerAction(fd);
if (result.ok) {
toast.success("Müşteri silindi.");
onOpenChange(false);
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Müşteriyi sil</DialogTitle>
<DialogDescription>
<strong>{name}</strong> kalıcı olarak silinecek. Bu işlem geri alınamaz.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Siliniyor...
</>
) : (
<>
<Trash2 className="size-4" />
Sil
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,11 @@
export type CustomerRow = {
id: string;
name: string;
email: string;
phone: string;
taxId: string;
address: string;
notes: string;
status: "active" | "passive";
createdAt: string;
};
+54
View File
@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { UsageBanner } from "@/components/billing/usage-banner";
import { listCustomers } from "@/lib/appwrite/customer-queries";
import { getPlanUsage } from "@/lib/appwrite/plan-limits";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { CustomersClient } from "./components/customers-client";
export const metadata: Metadata = {
title: "İşletmem — Müşteriler",
};
export default async function CustomersPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const [customers, usage] = await Promise.all([
listCustomers(ctx.tenantId),
getPlanUsage(ctx),
]);
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
<h1 className="text-2xl font-bold tracking-tight">Müşteriler</h1>
<p className="text-muted-foreground text-sm">
Müşterilerinizi yönetin, hizmet ve yazılım ilişkilerini buradan kurun.
</p>
</div>
<UsageBanner usage={usage} resource="customers" />
<CustomersClient
customers={customers.map((c) => ({
id: c.$id,
name: c.name,
email: c.email ?? "",
phone: c.phone ?? "",
taxId: c.taxId ?? "",
address: c.address ?? "",
notes: c.notes ?? "",
status: (c.status ?? "active") as "active" | "passive",
createdAt: c.$createdAt,
}))}
/>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import React from "react";
import { AppSidebar } from "@/components/app-sidebar";
import { SiteHeader } from "@/components/site-header";
import { SiteFooter } from "@/components/site-footer";
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
import { ThemeCustomizer, ThemeCustomizerTrigger } from "@/components/theme-customizer";
import { useSidebarConfig } from "@/hooks/use-sidebar-config";
export type ShellUser = {
id: string;
name: string;
email: string;
};
export type ShellCompany = {
id: string;
name: string;
logoUrl?: string | null;
};
export function DashboardShell({
user,
company,
children,
}: {
user: ShellUser;
company: ShellCompany;
children: React.ReactNode;
}) {
const [themeCustomizerOpen, setThemeCustomizerOpen] = React.useState(false);
const { config } = useSidebarConfig();
return (
<SidebarProvider
style={
{
"--sidebar-width": "16rem",
"--sidebar-width-icon": "3rem",
"--header-height": "calc(var(--spacing) * 14)",
} as React.CSSProperties
}
className={config.collapsible === "none" ? "sidebar-none-mode" : ""}
>
{config.side === "left" ? (
<>
<AppSidebar
user={user}
company={company}
variant={config.variant}
collapsible={config.collapsible}
side={config.side}
/>
<SidebarInset>
<SiteHeader company={company} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">{children}</div>
</div>
</div>
<SiteFooter />
</SidebarInset>
</>
) : (
<>
<SidebarInset>
<SiteHeader company={company} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">{children}</div>
</div>
</div>
<SiteFooter />
</SidebarInset>
<AppSidebar
user={user}
company={company}
variant={config.variant}
collapsible={config.collapsible}
side={config.side}
/>
</>
)}
<ThemeCustomizerTrigger onClick={() => setThemeCustomizerOpen(true)} />
<ThemeCustomizer
open={themeCustomizerOpen}
onOpenChange={setThemeCustomizerOpen}
/>
</SidebarProvider>
);
}
@@ -0,0 +1,291 @@
"use client"
import * as React from "react"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { useIsMobile } from "@/hooks/use-mobile"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group"
export const description = "An interactive area chart"
const chartData = [
{ date: "2024-04-01", desktop: 222, mobile: 150 },
{ date: "2024-04-02", desktop: 97, mobile: 180 },
{ date: "2024-04-03", desktop: 167, mobile: 120 },
{ date: "2024-04-04", desktop: 242, mobile: 260 },
{ date: "2024-04-05", desktop: 373, mobile: 290 },
{ date: "2024-04-06", desktop: 301, mobile: 340 },
{ date: "2024-04-07", desktop: 245, mobile: 180 },
{ date: "2024-04-08", desktop: 409, mobile: 320 },
{ date: "2024-04-09", desktop: 59, mobile: 110 },
{ date: "2024-04-10", desktop: 261, mobile: 190 },
{ date: "2024-04-11", desktop: 327, mobile: 350 },
{ date: "2024-04-12", desktop: 292, mobile: 210 },
{ date: "2024-04-13", desktop: 342, mobile: 380 },
{ date: "2024-04-14", desktop: 137, mobile: 220 },
{ date: "2024-04-15", desktop: 120, mobile: 170 },
{ date: "2024-04-16", desktop: 138, mobile: 190 },
{ date: "2024-04-17", desktop: 446, mobile: 360 },
{ date: "2024-04-18", desktop: 364, mobile: 410 },
{ date: "2024-04-19", desktop: 243, mobile: 180 },
{ date: "2024-04-20", desktop: 89, mobile: 150 },
{ date: "2024-04-21", desktop: 137, mobile: 200 },
{ date: "2024-04-22", desktop: 224, mobile: 170 },
{ date: "2024-04-23", desktop: 138, mobile: 230 },
{ date: "2024-04-24", desktop: 387, mobile: 290 },
{ date: "2024-04-25", desktop: 215, mobile: 250 },
{ date: "2024-04-26", desktop: 75, mobile: 130 },
{ date: "2024-04-27", desktop: 383, mobile: 420 },
{ date: "2024-04-28", desktop: 122, mobile: 180 },
{ date: "2024-04-29", desktop: 315, mobile: 240 },
{ date: "2024-04-30", desktop: 454, mobile: 380 },
{ date: "2024-05-01", desktop: 165, mobile: 220 },
{ date: "2024-05-02", desktop: 293, mobile: 310 },
{ date: "2024-05-03", desktop: 247, mobile: 190 },
{ date: "2024-05-04", desktop: 385, mobile: 420 },
{ date: "2024-05-05", desktop: 481, mobile: 390 },
{ date: "2024-05-06", desktop: 498, mobile: 520 },
{ date: "2024-05-07", desktop: 388, mobile: 300 },
{ date: "2024-05-08", desktop: 149, mobile: 210 },
{ date: "2024-05-09", desktop: 227, mobile: 180 },
{ date: "2024-05-10", desktop: 293, mobile: 330 },
{ date: "2024-05-11", desktop: 335, mobile: 270 },
{ date: "2024-05-12", desktop: 197, mobile: 240 },
{ date: "2024-05-13", desktop: 197, mobile: 160 },
{ date: "2024-05-14", desktop: 448, mobile: 490 },
{ date: "2024-05-15", desktop: 473, mobile: 380 },
{ date: "2024-05-16", desktop: 338, mobile: 400 },
{ date: "2024-05-17", desktop: 499, mobile: 420 },
{ date: "2024-05-18", desktop: 315, mobile: 350 },
{ date: "2024-05-19", desktop: 235, mobile: 180 },
{ date: "2024-05-20", desktop: 177, mobile: 230 },
{ date: "2024-05-21", desktop: 82, mobile: 140 },
{ date: "2024-05-22", desktop: 81, mobile: 120 },
{ date: "2024-05-23", desktop: 252, mobile: 290 },
{ date: "2024-05-24", desktop: 294, mobile: 220 },
{ date: "2024-05-25", desktop: 201, mobile: 250 },
{ date: "2024-05-26", desktop: 213, mobile: 170 },
{ date: "2024-05-27", desktop: 420, mobile: 460 },
{ date: "2024-05-28", desktop: 233, mobile: 190 },
{ date: "2024-05-29", desktop: 78, mobile: 130 },
{ date: "2024-05-30", desktop: 340, mobile: 280 },
{ date: "2024-05-31", desktop: 178, mobile: 230 },
{ date: "2024-06-01", desktop: 178, mobile: 200 },
{ date: "2024-06-02", desktop: 470, mobile: 410 },
{ date: "2024-06-03", desktop: 103, mobile: 160 },
{ date: "2024-06-04", desktop: 439, mobile: 380 },
{ date: "2024-06-05", desktop: 88, mobile: 140 },
{ date: "2024-06-06", desktop: 294, mobile: 250 },
{ date: "2024-06-07", desktop: 323, mobile: 370 },
{ date: "2024-06-08", desktop: 385, mobile: 320 },
{ date: "2024-06-09", desktop: 438, mobile: 480 },
{ date: "2024-06-10", desktop: 155, mobile: 200 },
{ date: "2024-06-11", desktop: 92, mobile: 150 },
{ date: "2024-06-12", desktop: 492, mobile: 420 },
{ date: "2024-06-13", desktop: 81, mobile: 130 },
{ date: "2024-06-14", desktop: 426, mobile: 380 },
{ date: "2024-06-15", desktop: 307, mobile: 350 },
{ date: "2024-06-16", desktop: 371, mobile: 310 },
{ date: "2024-06-17", desktop: 475, mobile: 520 },
{ date: "2024-06-18", desktop: 107, mobile: 170 },
{ date: "2024-06-19", desktop: 341, mobile: 290 },
{ date: "2024-06-20", desktop: 408, mobile: 450 },
{ date: "2024-06-21", desktop: 169, mobile: 210 },
{ date: "2024-06-22", desktop: 317, mobile: 270 },
{ date: "2024-06-23", desktop: 480, mobile: 530 },
{ date: "2024-06-24", desktop: 132, mobile: 180 },
{ date: "2024-06-25", desktop: 141, mobile: 190 },
{ date: "2024-06-26", desktop: 434, mobile: 380 },
{ date: "2024-06-27", desktop: 448, mobile: 490 },
{ date: "2024-06-28", desktop: 149, mobile: 200 },
{ date: "2024-06-29", desktop: 103, mobile: 160 },
{ date: "2024-06-30", desktop: 446, mobile: 400 },
]
const chartConfig = {
visitors: {
label: "Visitors",
},
desktop: {
label: "Desktop",
color: "var(--primary)",
},
mobile: {
label: "Mobile",
color: "var(--primary)",
},
} satisfies ChartConfig
export function ChartAreaInteractive() {
const isMobile = useIsMobile()
const [timeRange, setTimeRange] = React.useState("90d")
React.useEffect(() => {
if (isMobile) {
setTimeRange("7d")
}
}, [isMobile])
const filteredData = chartData.filter((item) => {
const date = new Date(item.date)
const referenceDate = new Date("2024-06-30")
let daysToSubtract = 90
if (timeRange === "30d") {
daysToSubtract = 30
} else if (timeRange === "7d") {
daysToSubtract = 7
}
const startDate = new Date(referenceDate)
startDate.setDate(startDate.getDate() - daysToSubtract)
return date >= startDate
})
return (
<Card className="@container/card">
<CardHeader>
<CardTitle>Total Visitors</CardTitle>
<CardDescription>
<span className="hidden @[540px]/card:block">
Total for the last 3 months
</span>
<span className="@[540px]/card:hidden">Last 3 months</span>
</CardDescription>
<CardAction>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
>
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
</ToggleGroup>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
size="sm"
aria-label="Select a value"
>
<SelectValue placeholder="Last 3 months" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="90d" className="rounded-lg">
Last 3 months
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
Last 30 days
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
Last 7 days
</SelectItem>
</SelectContent>
</Select>
</CardAction>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={filteredData}>
<defs>
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-desktop)"
stopOpacity={1.0}
/>
<stop
offset="95%"
stopColor="var(--color-desktop)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-mobile)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-mobile)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value)
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) => {
return new Date(value as string | number | Date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
indicator="dot"
/>
}
/>
<Area
dataKey="mobile"
type="natural"
fill="url(#fillMobile)"
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="url(#fillDesktop)"
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)
}
@@ -0,0 +1,59 @@
"use client";
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
type Point = { month: string; count: number };
export function CustomerGrowth({ data }: { data: Point[] }) {
const total = data.reduce((s, p) => s + p.count, 0);
return (
<Card>
<CardHeader>
<CardTitle>Yeni müşteriler</CardTitle>
<CardDescription>Son 6 ay toplam {total} yeni müşteri</CardDescription>
</CardHeader>
<CardContent className="h-[220px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
fontSize={11}
stroke="hsl(var(--muted-foreground))"
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={11}
stroke="hsl(var(--muted-foreground))"
allowDecimals={false}
/>
<Tooltip
cursor={{ fill: "hsl(var(--muted))" }}
contentStyle={{
background: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: 8,
fontSize: 12,
}}
formatter={(value: unknown) => [`${value} müşteri`, "Yeni"]}
/>
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
"use client";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { formatTRY } from "@/lib/format";
type Point = { month: string; income: number; expense: number };
export function IncomeChart({ data }: { data: Point[] }) {
const total = data.reduce((s, p) => s + p.income, 0);
return (
<Card className="@container">
<CardHeader>
<CardTitle>Gelir / Gider</CardTitle>
<CardDescription>
Son 12 ay toplam gelir {formatTRY(total)}
</CardDescription>
</CardHeader>
<CardContent className="h-[280px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<defs>
<linearGradient id="incomeGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.4} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
<linearGradient id="expenseGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
fontSize={11}
stroke="hsl(var(--muted-foreground))"
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={11}
stroke="hsl(var(--muted-foreground))"
tickFormatter={(v) =>
v >= 1000 ? `${(v / 1000).toFixed(0)}k` : String(v)
}
/>
<Tooltip
contentStyle={{
background: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: 8,
fontSize: 12,
}}
formatter={(value: number, name: string) => [
formatTRY(value),
name === "income" ? "Gelir" : "Gider",
]}
/>
<Area
type="monotone"
dataKey="income"
stroke="#10b981"
strokeWidth={2}
fill="url(#incomeGradient)"
/>
<Area
type="monotone"
dataKey="expense"
stroke="#ef4444"
strokeWidth={2}
fill="url(#expenseGradient)"
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
@@ -0,0 +1,116 @@
import {
AlertCircle,
ArrowDownRight,
ArrowUpRight,
CheckSquare,
Receipt,
Users,
Wallet,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { DashboardData } from "@/lib/appwrite/dashboard-queries";
function delta(current: number, previous: number): { pct: number; positive: boolean } | null {
if (previous === 0) {
if (current === 0) return null;
return { pct: 100, positive: true };
}
const pct = ((current - previous) / previous) * 100;
return { pct: Math.abs(pct), positive: pct >= 0 };
}
export function Metrics({ data }: { data: DashboardData["metrics"] }) {
const incomeDelta = delta(data.monthIncome, data.prevMonthIncome);
const cards = [
{
label: "Müşteriler",
value: String(data.totalCustomers),
sub: `${data.activeCustomers} aktif`,
icon: Users,
tone: "default",
},
{
label: "Bu ayki gelir",
value: formatTRY(data.monthIncome),
sub: incomeDelta
? `${incomeDelta.positive ? "+" : ""}${incomeDelta.pct.toFixed(1)}% önceki ay`
: "Geçen ay veri yok",
icon: Wallet,
tone: "income",
trend: incomeDelta,
},
{
label: "Bekleyen tahsilat",
value: formatTRY(data.outstanding),
sub:
data.overdueCount > 0
? `${data.overdueCount} vadesi geçmiş`
: "Vadesi geçmiş yok",
icon: Receipt,
tone: data.overdueCount > 0 ? "warning" : "default",
},
{
label: "Açık görevlerim",
value: String(data.openTasks),
sub:
data.urgentTasks > 0
? `${data.urgentTasks} acil`
: data.openTasks === 0
? "Hepsi tamam"
: "Atanmış + atanmamış",
icon: CheckSquare,
tone: data.urgentTasks > 0 ? "warning" : "default",
},
];
const toneClass: Record<string, string> = {
default: "text-muted-foreground",
income: "text-emerald-600 dark:text-emerald-400",
warning: "text-amber-600 dark:text-amber-400",
};
return (
<div className="grid gap-4 sm:grid-cols-2 @5xl:grid-cols-4">
{cards.map((c) => {
const Icon = c.icon;
return (
<Card key={c.label}>
<CardContent className="flex items-start justify-between p-5">
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{c.label}
</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">{c.value}</p>
<p
className={cn(
"mt-1 flex items-center gap-1 text-xs",
c.tone === "warning" && data.overdueCount + data.urgentTasks > 0
? "text-amber-600 dark:text-amber-400"
: "text-muted-foreground",
)}
>
{c.trend &&
(c.trend.positive ? (
<ArrowUpRight className="text-emerald-600 dark:text-emerald-400 size-3" />
) : (
<ArrowDownRight className="text-red-600 dark:text-red-400 size-3" />
))}
{c.tone === "warning" && data.overdueCount + data.urgentTasks > 0 && (
<AlertCircle className="size-3" />
)}
{c.sub}
</p>
</div>
<Icon className={cn("size-5", toneClass[c.tone])} />
</CardContent>
</Card>
);
})}
</div>
);
}
@@ -0,0 +1,35 @@
import Link from "next/link";
import { Calendar, FilePlus, Receipt, UserPlus } from "lucide-react";
import { Button } from "@/components/ui/button";
export function QuickActions() {
return (
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/customers">
<UserPlus className="size-3.5" />
Müşteri
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/invoices">
<Receipt className="size-3.5" />
Fatura
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/calendar">
<Calendar className="size-3.5" />
Etkinlik
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/tasks">
<FilePlus className="size-3.5" />
Görev
</Link>
</Button>
</div>
);
}
@@ -0,0 +1,82 @@
import Link from "next/link";
import { ArrowRight, Receipt } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { formatDate, formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { DashboardData } from "@/lib/appwrite/dashboard-queries";
const TYPE_LABEL: Record<string, string> = {
income: "Gelir",
expense: "Gider",
debt: "Borç",
receivable: "Alacak",
};
const TYPE_COLOR: Record<string, string> = {
income: "text-emerald-600 dark:text-emerald-400",
expense: "text-red-600 dark:text-red-400",
debt: "text-amber-600 dark:text-amber-400",
receivable: "text-blue-600 dark:text-blue-400",
};
export function RecentTransactions({
data,
}: {
data: DashboardData["recentTransactions"];
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Son işlemler</CardTitle>
<CardDescription>En son finans hareketleri</CardDescription>
</div>
<Button asChild variant="ghost" size="sm">
<Link href="/finance">
Tümü <ArrowRight className="size-3.5" />
</Link>
</Button>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-center gap-2 py-10 text-sm">
<Receipt className="size-6" />
<p>Henüz finans hareketi yok.</p>
</div>
) : (
<ul className="divide-y">
{data.map((t) => {
const sign =
t.type === "income" || t.type === "receivable" ? "+" : "";
return (
<li key={t.id} className="flex items-center justify-between py-2.5">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{TYPE_LABEL[t.type]}
</Badge>
<span className="text-muted-foreground text-xs">
{formatDate(t.date)}
</span>
</div>
<p className="mt-0.5 truncate text-sm">
{t.customerName ? `${t.customerName}` : ""}
{t.description || "—"}
</p>
</div>
<span className={cn("font-medium tabular-nums", TYPE_COLOR[t.type])}>
{sign} {formatTRY(t.amount)}
</span>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,102 @@
import { TrendingDown, TrendingUp } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import {
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
export function SectionCards() {
return (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Card className="@container/card">
<CardHeader>
<CardDescription>Total Revenue</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
$1,250.00
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Trending up this month <TrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">
Visitors for the last 6 months
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>New Customers</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
1,234
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendingDown />
-20%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Down 20% this period <TrendingDown className="size-4" />
</div>
<div className="text-muted-foreground">
Acquisition needs attention
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Active Accounts</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
45,678
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Strong user retention <TrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">Engagement exceed targets</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Growth Rate</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
4.5%
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendingUp />
+4.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Steady performance increase <TrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">Meets growth projections</div>
</CardFooter>
</Card>
</div>
)
}
@@ -0,0 +1,63 @@
import { Crown, TrendingUp } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
type Item = { name: string; total: number };
export function TopCustomers({ data }: { data: Item[] }) {
const max = data[0]?.total ?? 1;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Crown className="size-4" />
En çok ciro yapan müşteriler
</CardTitle>
<CardDescription>Ödenmiş faturaların toplam tutarına göre</CardDescription>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-center gap-2 py-10 text-sm">
<TrendingUp className="size-6" />
<p>Henüz ödenmiş fatura yok.</p>
</div>
) : (
<ul className="space-y-3">
{data.map((c, i) => {
const width = (c.total / max) * 100;
return (
<li key={c.name + i} className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-sm font-medium">
<span className="text-muted-foreground mr-2 tabular-nums">
{String(i + 1).padStart(2, "0")}
</span>
{c.name}
</span>
<span className="text-sm tabular-nums">{formatTRY(c.total)}</span>
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div
className={cn(
"h-full rounded-full",
i === 0
? "bg-emerald-500"
: i === 1
? "bg-emerald-400"
: "bg-emerald-300",
)}
style={{ width: `${width}%` }}
/>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,614 @@
[
{
"id": 1,
"header": "Cover page",
"type": "Cover page",
"status": "In Process",
"target": "18",
"limit": "5",
"reviewer": "Eddie Lake"
},
{
"id": 2,
"header": "Table of contents",
"type": "Table of contents",
"status": "Done",
"target": "29",
"limit": "24",
"reviewer": "Eddie Lake"
},
{
"id": 3,
"header": "Executive summary",
"type": "Narrative",
"status": "Done",
"target": "10",
"limit": "13",
"reviewer": "Eddie Lake"
},
{
"id": 4,
"header": "Technical approach",
"type": "Narrative",
"status": "Done",
"target": "27",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 5,
"header": "Design",
"type": "Narrative",
"status": "In Process",
"target": "2",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 6,
"header": "Capabilities",
"type": "Narrative",
"status": "In Process",
"target": "20",
"limit": "8",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 7,
"header": "Integration with existing systems",
"type": "Narrative",
"status": "In Process",
"target": "19",
"limit": "21",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 8,
"header": "Innovation and Advantages",
"type": "Narrative",
"status": "Done",
"target": "25",
"limit": "26",
"reviewer": "Assign reviewer"
},
{
"id": 9,
"header": "Overview of EMR's Innovative Solutions",
"type": "Technical content",
"status": "Done",
"target": "7",
"limit": "23",
"reviewer": "Assign reviewer"
},
{
"id": 10,
"header": "Advanced Algorithms and Machine Learning",
"type": "Narrative",
"status": "Done",
"target": "30",
"limit": "28",
"reviewer": "Assign reviewer"
},
{
"id": 11,
"header": "Adaptive Communication Protocols",
"type": "Narrative",
"status": "Done",
"target": "9",
"limit": "31",
"reviewer": "Assign reviewer"
},
{
"id": 12,
"header": "Advantages Over Current Technologies",
"type": "Narrative",
"status": "Done",
"target": "12",
"limit": "0",
"reviewer": "Assign reviewer"
},
{
"id": 13,
"header": "Past Performance",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "33",
"reviewer": "Assign reviewer"
},
{
"id": 14,
"header": "Customer Feedback and Satisfaction Levels",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "34",
"reviewer": "Assign reviewer"
},
{
"id": 15,
"header": "Implementation Challenges and Solutions",
"type": "Narrative",
"status": "Done",
"target": "3",
"limit": "35",
"reviewer": "Assign reviewer"
},
{
"id": 16,
"header": "Security Measures and Data Protection Policies",
"type": "Narrative",
"status": "In Process",
"target": "6",
"limit": "36",
"reviewer": "Assign reviewer"
},
{
"id": 17,
"header": "Scalability and Future Proofing",
"type": "Narrative",
"status": "Done",
"target": "4",
"limit": "37",
"reviewer": "Assign reviewer"
},
{
"id": 18,
"header": "Cost-Benefit Analysis",
"type": "Plain language",
"status": "Done",
"target": "14",
"limit": "38",
"reviewer": "Assign reviewer"
},
{
"id": 19,
"header": "User Training and Onboarding Experience",
"type": "Narrative",
"status": "Done",
"target": "17",
"limit": "39",
"reviewer": "Assign reviewer"
},
{
"id": 20,
"header": "Future Development Roadmap",
"type": "Narrative",
"status": "Done",
"target": "11",
"limit": "40",
"reviewer": "Assign reviewer"
},
{
"id": 21,
"header": "System Architecture Overview",
"type": "Technical content",
"status": "In Process",
"target": "24",
"limit": "18",
"reviewer": "Maya Johnson"
},
{
"id": 22,
"header": "Risk Management Plan",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "22",
"reviewer": "Carlos Rodriguez"
},
{
"id": 23,
"header": "Compliance Documentation",
"type": "Legal",
"status": "In Process",
"target": "31",
"limit": "27",
"reviewer": "Sarah Chen"
},
{
"id": 24,
"header": "API Documentation",
"type": "Technical content",
"status": "Done",
"target": "8",
"limit": "12",
"reviewer": "Raj Patel"
},
{
"id": 25,
"header": "User Interface Mockups",
"type": "Visual",
"status": "In Process",
"target": "19",
"limit": "25",
"reviewer": "Leila Ahmadi"
},
{
"id": 26,
"header": "Database Schema",
"type": "Technical content",
"status": "Done",
"target": "22",
"limit": "20",
"reviewer": "Thomas Wilson"
},
{
"id": 27,
"header": "Testing Methodology",
"type": "Technical content",
"status": "In Process",
"target": "17",
"limit": "14",
"reviewer": "Assign reviewer"
},
{
"id": 28,
"header": "Deployment Strategy",
"type": "Narrative",
"status": "Done",
"target": "26",
"limit": "30",
"reviewer": "Eddie Lake"
},
{
"id": 29,
"header": "Budget Breakdown",
"type": "Financial",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 30,
"header": "Market Analysis",
"type": "Research",
"status": "Done",
"target": "29",
"limit": "32",
"reviewer": "Sophia Martinez"
},
{
"id": 31,
"header": "Competitor Comparison",
"type": "Research",
"status": "In Process",
"target": "21",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 32,
"header": "Maintenance Plan",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "23",
"reviewer": "Alex Thompson"
},
{
"id": 33,
"header": "User Personas",
"type": "Research",
"status": "In Process",
"target": "27",
"limit": "24",
"reviewer": "Nina Patel"
},
{
"id": 34,
"header": "Accessibility Compliance",
"type": "Legal",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 35,
"header": "Performance Metrics",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "David Kim"
},
{
"id": 36,
"header": "Disaster Recovery Plan",
"type": "Technical content",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 37,
"header": "Third-party Integrations",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Eddie Lake"
},
{
"id": 38,
"header": "User Feedback Summary",
"type": "Research",
"status": "Done",
"target": "20",
"limit": "15",
"reviewer": "Assign reviewer"
},
{
"id": 39,
"header": "Localization Strategy",
"type": "Narrative",
"status": "In Process",
"target": "12",
"limit": "19",
"reviewer": "Maria Garcia"
},
{
"id": 40,
"header": "Mobile Compatibility",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "James Wilson"
},
{
"id": 41,
"header": "Data Migration Plan",
"type": "Technical content",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Assign reviewer"
},
{
"id": 42,
"header": "Quality Assurance Protocols",
"type": "Technical content",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Priya Singh"
},
{
"id": 43,
"header": "Stakeholder Analysis",
"type": "Research",
"status": "In Process",
"target": "11",
"limit": "14",
"reviewer": "Eddie Lake"
},
{
"id": 44,
"header": "Environmental Impact Assessment",
"type": "Research",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Assign reviewer"
},
{
"id": 45,
"header": "Intellectual Property Rights",
"type": "Legal",
"status": "In Process",
"target": "17",
"limit": "20",
"reviewer": "Sarah Johnson"
},
{
"id": 46,
"header": "Customer Support Framework",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 47,
"header": "Version Control Strategy",
"type": "Technical content",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 48,
"header": "Continuous Integration Pipeline",
"type": "Technical content",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Michael Chen"
},
{
"id": 49,
"header": "Regulatory Compliance",
"type": "Legal",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Assign reviewer"
},
{
"id": 50,
"header": "User Authentication System",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "Eddie Lake"
},
{
"id": 51,
"header": "Data Analytics Framework",
"type": "Technical content",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 52,
"header": "Cloud Infrastructure",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 53,
"header": "Network Security Measures",
"type": "Technical content",
"status": "In Process",
"target": "29",
"limit": "32",
"reviewer": "Lisa Wong"
},
{
"id": 54,
"header": "Project Timeline",
"type": "Planning",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Eddie Lake"
},
{
"id": 55,
"header": "Resource Allocation",
"type": "Planning",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Assign reviewer"
},
{
"id": 56,
"header": "Team Structure and Roles",
"type": "Planning",
"status": "Done",
"target": "20",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 57,
"header": "Communication Protocols",
"type": "Planning",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 58,
"header": "Success Metrics",
"type": "Planning",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Eddie Lake"
},
{
"id": 59,
"header": "Internationalization Support",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 60,
"header": "Backup and Recovery Procedures",
"type": "Technical content",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 61,
"header": "Monitoring and Alerting System",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Daniel Park"
},
{
"id": 62,
"header": "Code Review Guidelines",
"type": "Technical content",
"status": "Done",
"target": "12",
"limit": "15",
"reviewer": "Eddie Lake"
},
{
"id": 63,
"header": "Documentation Standards",
"type": "Technical content",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 64,
"header": "Release Management Process",
"type": "Planning",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Assign reviewer"
},
{
"id": 65,
"header": "Feature Prioritization Matrix",
"type": "Planning",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Emma Davis"
},
{
"id": 66,
"header": "Technical Debt Assessment",
"type": "Technical content",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Eddie Lake"
},
{
"id": 67,
"header": "Capacity Planning",
"type": "Planning",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 68,
"header": "Service Level Agreements",
"type": "Legal",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Assign reviewer"
}
]
@@ -0,0 +1,47 @@
[
{
"id": 1,
"header": "Technical Specifications Document v2.1",
"type": "Technical Document",
"status": "Final",
"target": "100%",
"limit": "100%",
"reviewer": "Eddie Lake"
},
{
"id": 2,
"header": "Security Compliance Report Q4 2024",
"type": "Compliance Document",
"status": "Under Review",
"target": "95%",
"limit": "100%",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 3,
"header": "Project Management Plan v3.0",
"type": "Management Document",
"status": "Final",
"target": "100%",
"limit": "100%",
"reviewer": "Emily Whalen"
},
{
"id": 4,
"header": "Risk Assessment Matrix 2025",
"type": "Risk Document",
"status": "Draft",
"target": "80%",
"limit": "90%",
"reviewer": "Eddie Lake"
},
{
"id": 5,
"header": "Quality Assurance Protocol v1.5",
"type": "QA Document",
"status": "Final",
"target": "100%",
"limit": "100%",
"reviewer": "Jamik Tashpulatov"
}
]
@@ -0,0 +1,47 @@
[
{
"id": 1,
"header": "Dr. Sarah Mitchell",
"type": "Project Manager",
"status": "Active",
"target": "15 years",
"limit": "20 years",
"reviewer": "Eddie Lake"
},
{
"id": 2,
"header": "James Thompson",
"type": "Lead Engineer",
"status": "Active",
"target": "12 years",
"limit": "15 years",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 3,
"header": "Maria Rodriguez",
"type": "Security Specialist",
"status": "Active",
"target": "8 years",
"limit": "10 years",
"reviewer": "Emily Whalen"
},
{
"id": 4,
"header": "David Chen",
"type": "Systems Architect",
"status": "Active",
"target": "10 years",
"limit": "12 years",
"reviewer": "Eddie Lake"
},
{
"id": 5,
"header": "Lisa Johnson",
"type": "Quality Assurance Lead",
"status": "Active",
"target": "6 years",
"limit": "8 years",
"reviewer": "Jamik Tashpulatov"
}
]
@@ -0,0 +1,47 @@
[
{
"id": 1,
"header": "Federal Communications Commission - Network Infrastructure Modernization",
"type": "Government Contract",
"status": "Completed",
"target": "95%",
"limit": "100%",
"reviewer": "Eddie Lake"
},
{
"id": 2,
"header": "Department of Defense - Cybersecurity Enhancement Program",
"type": "Defense Contract",
"status": "Completed",
"target": "98%",
"limit": "100%",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 3,
"header": "NASA - Satellite Communication System Upgrade",
"type": "Space Technology",
"status": "Completed",
"target": "92%",
"limit": "95%",
"reviewer": "Emily Whalen"
},
{
"id": 4,
"header": "Department of Homeland Security - Border Security Tech",
"type": "Security Contract",
"status": "In Progress",
"target": "85%",
"limit": "90%",
"reviewer": "Eddie Lake"
},
{
"id": 5,
"header": "GSA - Cloud Infrastructure Migration",
"type": "IT Services",
"status": "Completed",
"target": "96%",
"limit": "98%",
"reviewer": "Jamik Tashpulatov"
}
]
+52
View File
@@ -0,0 +1,52 @@
import { redirect } from "next/navigation";
import { getActiveContext } from "@/lib/appwrite/active-context";
import { getDashboardData } from "@/lib/appwrite/dashboard-queries";
import { CustomerGrowth } from "./components/customer-growth";
import { IncomeChart } from "./components/income-chart";
import { Metrics } from "./components/metrics";
import { QuickActions } from "./components/quick-actions";
import { RecentTransactions } from "./components/recent-transactions";
import { TopCustomers } from "./components/top-customers";
export default async function DashboardPage() {
const ctx = await getActiveContext();
if (!ctx) redirect("/onboarding");
const data = await getDashboardData(ctx.tenantId, ctx.user.id);
const firstName = ctx.user.name?.split(" ")[0] ?? "";
const companyName = ctx.settings?.companyName ?? "Çalışma alanı";
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center md:gap-6">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">{companyName}</p>
<h1 className="text-2xl font-bold tracking-tight">
{firstName ? `Hoş geldiniz, ${firstName}` : "Genel bakış"}
</h1>
<p className="text-muted-foreground text-sm">
İşletmenizin temel metriklerini ve son hareketleri buradan takip edin.
</p>
</div>
<QuickActions />
</div>
<div className="@container/main space-y-6">
<Metrics data={data.metrics} />
<div className="grid grid-cols-1 gap-6 @5xl:grid-cols-2">
<IncomeChart data={data.monthlyIncome} />
<TopCustomers data={data.topCustomers} />
</div>
<div className="grid grid-cols-1 gap-6 @5xl:grid-cols-2">
<RecentTransactions data={data.recentTransactions} />
<CustomerGrowth data={data.newCustomersMonthly} />
</div>
</div>
</div>
);
}
@@ -0,0 +1,13 @@
import { z } from "zod"
export const schema = z.object({
id: z.number(),
header: z.string(),
type: z.string(),
status: z.string(),
target: z.string(),
limit: z.string(),
reviewer: z.string(),
})
export type Task = z.infer<typeof schema>
@@ -0,0 +1,129 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { cn } from "@/lib/utils"
import { Search } from "lucide-react"
interface FAQ {
id: number
question: string
answer: string
category: string
}
interface Category {
name: string
count: number
}
interface FAQListProps {
faqs: FAQ[]
categories: Category[]
}
export function FAQList({ faqs, categories }: FAQListProps) {
const [selectedCategory, setSelectedCategory] = useState("All")
const [searchQuery, setSearchQuery] = useState("")
// Filter FAQs based on selected category and search query
const filteredFaqs = faqs.filter(faq => {
const matchesCategory = selectedCategory === "All" || faq.category === selectedCategory
const matchesSearch = searchQuery === "" ||
faq.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
faq.answer.toLowerCase().includes(searchQuery.toLowerCase())
return matchesCategory && matchesSearch
})
return (
<div className="grid grid-cols-1 lg:grid-cols-6 xl:grid-cols-4 gap-6">
{/* Categories Sidebar */}
<Card className="lg:col-span-2 xl:col-span-1">
<CardHeader>
<CardTitle className="text-lg">Categories</CardTitle>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search FAQs..."
className="pl-10 cursor-pointer"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</CardHeader>
<CardContent className="space-y-2">
{categories.map((category) => (
<div
key={category.name}
className={cn(
"flex items-center justify-between px-3 py-2 rounded-lg hover:bg-muted cursor-pointer transition-colors group",
selectedCategory === category.name && "bg-muted"
)}
onClick={() => setSelectedCategory(category.name)}
>
<span className="font-medium">{category.name}</span>
<Badge
variant="secondary"
className={cn(
"transition-colors",
selectedCategory === category.name && "bg-background"
)}
>
{category.name === "All" ? faqs.length : category.count}
</Badge>
</div>
))}
</CardContent>
</Card>
{/* FAQs List */}
<div className="lg:col-span-4 xl:col-span-3">
<Card>
<CardHeader>
<CardTitle className="text-lg">
{selectedCategory === "All" ? "All FAQs" : `${selectedCategory} FAQs`}
<span className="text-sm font-normal text-muted-foreground ml-2">
({filteredFaqs.length} {filteredFaqs.length === 1 ? 'question' : 'questions'})
</span>
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[570px] pr-4">
{filteredFaqs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No FAQs found matching your search criteria.</p>
</div>
) : (
<Accordion type='single' className='space-y-4' defaultValue="item-1">
{filteredFaqs.map((item) => (
<AccordionItem
key={item.id}
value={`item-${item.id}`}
className='rounded-md !border'
>
<AccordionTrigger className='cursor-pointer px-4 hover:no-underline'>
<div className="flex items-start text-left">
<span>{item.question}</span>
<Badge variant="outline" className="ms-3 mt-0.5 shrink-0 text-xs">
{item.category}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className='text-muted-foreground px-4'>
{item.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
)
}
@@ -0,0 +1,54 @@
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from '@/components/ui/button'
import { ArrowRight, Sparkles, Shield, Truck, Clock } from 'lucide-react'
interface FeatureItem {
id: number
title: string
description: string
icon: string
}
interface FeaturesGridProps {
features: FeatureItem[]
}
const iconMap = {
Sparkles,
Shield,
Truck,
Clock,
}
export function FeaturesGrid({ features }: FeaturesGridProps) {
return (
<div className='grid gap-4 sm:grid-cols-2 sm:gap-6 xl:grid-cols-4 mt-8'>
{features.map(feature => {
const IconComponent = iconMap[feature.icon as keyof typeof iconMap]
return (
<article key={feature.id} className='group'>
<Card className='relative h-full overflow-hidden transition-all hover:shadow-md'>
<CardContent className='px-6'>
<Badge variant='secondary' className='mb-4 inline-flex size-12 items-center justify-center'>
<IconComponent className='!size-5' aria-hidden='true' />
</Badge>
<h3 className='mb-2 text-lg font-semibold'>{feature.title}</h3>
<p className='text-muted-foreground mb-4 text-sm'>{feature.description}</p>
<Button
variant='link'
size='sm'
className='text-muted-foreground hover:text-foreground h-auto cursor-pointer !p-0 text-sm'
>
Learn more
<ArrowRight className='ms-1.5 size-4' />
</Button>
</CardContent>
</Card>
</article>
)
})}
</div>
)
}
@@ -0,0 +1,10 @@
[
{ "name": "All", "count": 46 },
{ "name": "General", "count": 8 },
{ "name": "Account", "count": 6 },
{ "name": "Billing", "count": 8 },
{ "name": "Technical", "count": 9 },
{ "name": "Privacy", "count": 5 },
{ "name": "Security", "count": 4 },
{ "name": "Support", "count": 6 }
]
+278
View File
@@ -0,0 +1,278 @@
[
{
"id": 1,
"question": "What is ShadcnStore Admin?",
"answer": "ShadcnStore Admin is a comprehensive admin dashboard template built with React, TypeScript, and shadcn/ui components. It provides a complete solution for managing your e-commerce store or business operations.",
"category": "General"
},
{
"id": 2,
"question": "How do I get started?",
"answer": "You can get started by signing up for an account, choosing a plan that fits your needs, and following our quick setup guide to configure your dashboard.",
"category": "General"
},
{
"id": 3,
"question": "Do you offer a free trial?",
"answer": "Yes, we offer a 14-day free trial for all new users. No credit card is required to start the trial, and you can explore all features during this period.",
"category": "General"
},
{
"id": 4,
"question": "What browsers are supported?",
"answer": "We support all modern browsers including Chrome, Firefox, Safari, and Edge. For the best experience, we recommend using the latest version of your preferred browser.",
"category": "General"
},
{
"id": 5,
"question": "How do I contact support?",
"answer": "You can contact our support team through the support page, by email at support@shadcnstore.com, or through the live chat feature available 24/7.",
"category": "General"
},
{
"id": 6,
"question": "Is there a mobile app available?",
"answer": "Currently, we offer a responsive web application that works great on mobile devices. A dedicated mobile app is planned for future release.",
"category": "General"
},
{
"id": 7,
"question": "Can I customize the dashboard?",
"answer": "Yes, the dashboard is highly customizable. You can modify themes, layouts, add custom components, and configure various settings to match your brand.",
"category": "General"
},
{
"id": 8,
"question": "What integrations are available?",
"answer": "We offer integrations with popular services like Stripe, PayPal, Shopify, WooCommerce, Google Analytics, and many more through our API.",
"category": "General"
},
{
"id": 9,
"question": "How do I reset my password?",
"answer": "You can reset your password by clicking on the 'Forgot Password' link on the login page. Enter your email address, and we'll send you instructions to reset your password.",
"category": "Account"
},
{
"id": 10,
"question": "How do I change my email address?",
"answer": "You can change your email address in your account settings under the 'User Settings' section. You'll need to verify the new email address before the change takes effect.",
"category": "Account"
},
{
"id": 11,
"question": "Can I have multiple team members?",
"answer": "Yes, depending on your plan, you can invite team members and assign different roles and permissions to manage your store collaboratively.",
"category": "Account"
},
{
"id": 12,
"question": "How do I delete my account?",
"answer": "To delete your account, go to your account settings and select 'Delete Account'. Please note that this action is irreversible and all data will be permanently removed.",
"category": "Account"
},
{
"id": 13,
"question": "Can I change my username?",
"answer": "Yes, you can change your username in the account settings. Keep in mind that some features might reference your old username temporarily.",
"category": "Account"
},
{
"id": 14,
"question": "How do I enable two-factor authentication?",
"answer": "You can enable two-factor authentication in your account security settings. We support both SMS and authenticator app methods for added security.",
"category": "Account"
},
{
"id": 15,
"question": "What payment methods do you accept?",
"answer": "We accept all major credit cards (Visa, MasterCard, American Express), PayPal, and bank transfers for enterprise customers. All payments are processed securely.",
"category": "Billing"
},
{
"id": 16,
"question": "How can I upgrade my plan?",
"answer": "You can upgrade your plan at any time from your account settings. Go to 'Plans & Billing' and select the plan that best fits your needs. Changes take effect immediately.",
"category": "Billing"
},
{
"id": 17,
"question": "Can I downgrade my plan?",
"answer": "Yes, you can downgrade your plan at any time. The change will take effect at the start of your next billing cycle to ensure you don't lose access to premium features.",
"category": "Billing"
},
{
"id": 18,
"question": "Do you offer refunds?",
"answer": "We offer a 30-day money-back guarantee for all plans. If you're not satisfied, contact our support team for a full refund within 30 days of purchase.",
"category": "Billing"
},
{
"id": 19,
"question": "How does billing work?",
"answer": "Billing is processed monthly or annually depending on your chosen plan. You'll receive an invoice before each billing cycle, and payment is automatically charged to your selected method.",
"category": "Billing"
},
{
"id": 20,
"question": "Can I change my billing cycle?",
"answer": "Yes, you can switch between monthly and annual billing at any time. Annual billing offers significant savings compared to monthly billing.",
"category": "Billing"
},
{
"id": 21,
"question": "What happens if payment fails?",
"answer": "If a payment fails, we'll attempt to charge your card again after 3 days. You'll receive email notifications, and your account will remain active during this grace period.",
"category": "Billing"
},
{
"id": 22,
"question": "How do I view my billing history?",
"answer": "You can view your complete billing history in the 'Plans & Billing' section of your account settings. All invoices and receipts are available for download.",
"category": "Billing"
},
{
"id": 23,
"question": "Can I export my data?",
"answer": "Yes, you can export your data at any time from your account settings. We provide exports in multiple formats including CSV, JSON, and PDF for different data types.",
"category": "Technical"
},
{
"id": 24,
"question": "What APIs do you provide?",
"answer": "We provide comprehensive REST APIs for all major features including product management, order processing, customer data, and analytics. Full documentation is available.",
"category": "Technical"
},
{
"id": 25,
"question": "How do I backup my data?",
"answer": "We automatically backup all your data daily. You can also create manual backups anytime from your settings, and restore from any backup point within the last 30 days.",
"category": "Technical"
},
{
"id": 26,
"question": "Is there a rate limit on API calls?",
"answer": "Yes, API rate limits vary by plan. Basic plans have 1000 calls/hour, Professional plans have 10,000 calls/hour, and Enterprise plans have unlimited calls.",
"category": "Technical"
},
{
"id": 27,
"question": "How do I set up webhooks?",
"answer": "Webhooks can be configured in the 'Connections' section of your settings. You can set up webhooks for various events like new orders, payment confirmations, and inventory updates.",
"category": "Technical"
},
{
"id": 28,
"question": "What about system maintenance?",
"answer": "We perform maintenance during low-traffic hours (typically Sunday 2-4 AM UTC). You'll be notified at least 48 hours in advance of any scheduled maintenance.",
"category": "Technical"
},
{
"id": 29,
"question": "How do I troubleshoot connection issues?",
"answer": "First, check your internet connection and try refreshing the page. If issues persist, check our status page or contact support with specific error messages.",
"category": "Technical"
},
{
"id": 30,
"question": "Can I use custom domains?",
"answer": "Yes, Professional and Enterprise plans support custom domains. You can configure your custom domain in the 'Connections' section of your account settings.",
"category": "Technical"
},
{
"id": 31,
"question": "What databases do you support?",
"answer": "We support integration with MySQL, PostgreSQL, MongoDB, and other popular databases through our Database Sync feature available in higher-tier plans.",
"category": "Technical"
},
{
"id": 32,
"question": "How do you handle my personal data?",
"answer": "We follow strict data protection policies and comply with GDPR, CCPA, and other privacy regulations. Your personal data is never shared with third parties without your consent.",
"category": "Privacy"
},
{
"id": 33,
"question": "Can I request my data?",
"answer": "Yes, you can request a complete copy of your personal data at any time. We'll provide it in a machine-readable format within 30 days of your request.",
"category": "Privacy"
},
{
"id": 34,
"question": "How long do you retain data?",
"answer": "We retain your data as long as your account is active. After account deletion, personal data is removed within 30 days, though some anonymized analytics may be retained.",
"category": "Privacy"
},
{
"id": 35,
"question": "Do you use cookies?",
"answer": "Yes, we use essential cookies for functionality and optional cookies for analytics and personalization. You can manage your cookie preferences in your account settings.",
"category": "Privacy"
},
{
"id": 36,
"question": "Is my data encrypted?",
"answer": "Yes, all data is encrypted both in transit (using TLS 1.3) and at rest (using AES-256 encryption). We use industry-standard security practices to protect your information.",
"category": "Privacy"
},
{
"id": 37,
"question": "How secure is my data?",
"answer": "We implement bank-level security with end-to-end encryption, regular security audits, and compliance with SOC 2 Type II standards. Your data security is our top priority.",
"category": "Security"
},
{
"id": 38,
"question": "Do you support SSO?",
"answer": "Yes, Enterprise plans include Single Sign-On (SSO) support with popular providers like Google, Microsoft Azure AD, and Okta for seamless team access.",
"category": "Security"
},
{
"id": 39,
"question": "What about password requirements?",
"answer": "We require strong passwords with at least 8 characters, including uppercase, lowercase, numbers, and special characters. We also highly recommend enabling two-factor authentication.",
"category": "Security"
},
{
"id": 40,
"question": "How do you handle security incidents?",
"answer": "We have a comprehensive incident response plan. In case of any security issues, we immediately investigate, contain the issue, and notify affected users within 24 hours.",
"category": "Security"
},
{
"id": 41,
"question": "What support channels are available?",
"answer": "We offer email support, live chat, and phone support (for Enterprise customers). Our knowledge base and community forums are also available 24/7.",
"category": "Support"
},
{
"id": 42,
"question": "What are your support hours?",
"answer": "Email and chat support are available 24/7. Phone support for Enterprise customers is available Monday-Friday, 9 AM-6 PM in your local timezone.",
"category": "Support"
},
{
"id": 43,
"question": "How quickly will I get a response?",
"answer": "Response times vary by plan: Basic (24 hours), Professional (12 hours), Enterprise (2 hours). Critical issues are prioritized and responded to immediately.",
"category": "Support"
},
{
"id": 44,
"question": "Do you offer training?",
"answer": "Yes, we provide comprehensive onboarding for all plans, video tutorials, documentation, and personalized training sessions for Enterprise customers.",
"category": "Support"
},
{
"id": 45,
"question": "Can you help with custom implementations?",
"answer": "Enterprise customers get access to our professional services team for custom implementations, integrations, and consulting services.",
"category": "Support"
},
{
"id": 46,
"question": "Is there a community forum?",
"answer": "Yes, we have an active community forum where users share tips, ask questions, and get help from both our team and other community members.",
"category": "Support"
}
]
@@ -0,0 +1,26 @@
[
{
"id": 1,
"title": "Premium Quality",
"description": "Handcrafted with premium materials and meticulous attention to detail.",
"icon": "Sparkles"
},
{
"id": 2,
"title": "Secure Shopping",
"description": "100% secure payment processing with end-to-end encryption.",
"icon": "Shield"
},
{
"id": 3,
"title": "Fast Delivery",
"description": "Free worldwide shipping and hassle-free returns within 30 days.",
"icon": "Truck"
},
{
"id": 4,
"title": "24/7 Support",
"description": "Round-the-clock customer support to assist you anytime.",
"icon": "Clock"
}
]
+16
View File
@@ -0,0 +1,16 @@
import { FAQList } from "./components/faq-list"
import { FeaturesGrid } from "./components/features-grid"
// Import data
import categoriesData from "./data/categories.json"
import faqsData from "./data/faqs.json"
import featuresData from "./data/features.json"
export default function FAQsPage() {
return (
<div className="px-4 lg:px-6">
<FAQList faqs={faqsData} categories={categoriesData} />
<FeaturesGrid features={featuresData} />
</div>
)
}
@@ -0,0 +1,162 @@
"use client";
import { useActionState, useEffect } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import {
createBankAccountAction,
updateBankAccountAction,
} from "@/lib/appwrite/bank-account-actions";
import { initialBankAccountState } from "@/lib/appwrite/bank-account-types";
import { ScopeToggle } from "@/components/finance/scope-toggle";
import type { BankAccountRow } from "./types";
type Props = {
open: boolean;
onOpenChange: (v: boolean) => void;
account?: BankAccountRow | null;
};
export function BankFormSheet({ open, onOpenChange, account }: Props) {
const isEdit = Boolean(account);
const action = isEdit ? updateBankAccountAction : createBankAccountAction;
const [state, formAction, isPending] = useActionState(action, initialBankAccountState);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Hesap güncellendi." : "Hesap eklendi.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Hesabı düzenle" : "Yeni banka hesabı"}</SheetTitle>
<SheetDescription>
Açılış bakiyesi sonradan değiştirilirse bütün hareketler aynı kalır, sadece toplam
kayar.
</SheetDescription>
</SheetHeader>
<form action={formAction} className="flex flex-1 flex-col">
{isEdit && account && <input type="hidden" name="id" value={account.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<ScopeToggle defaultValue={(account as { scope?: "company" | "personal" } | null)?.scope ?? "company"} />
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="bankName">Banka *</Label>
<Input
id="bankName"
name="bankName"
defaultValue={account?.bankName ?? ""}
placeholder="Örn. Garanti BBVA"
required
/>
{state.fieldErrors?.bankName && (
<p className="text-destructive text-xs">{state.fieldErrors.bankName}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="accountName">Hesap adı *</Label>
<Input
id="accountName"
name="accountName"
defaultValue={account?.accountName ?? ""}
placeholder="Örn. Şirket TL Vadesiz"
required
/>
{state.fieldErrors?.accountName && (
<p className="text-destructive text-xs">{state.fieldErrors.accountName}</p>
)}
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="iban">IBAN</Label>
<Input
id="iban"
name="iban"
defaultValue={account?.iban ?? ""}
placeholder="TR.. .... .... .... .... .... .."
style={{ fontFamily: "monospace", textTransform: "uppercase" }}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="openingBalance">Açılış bakiyesi ()</Label>
<Input
id="openingBalance"
name="openingBalance"
type="number"
step="0.01"
defaultValue={account?.openingBalance ?? 0}
placeholder="0.00"
/>
<p className="text-muted-foreground text-xs">
Bu hesabı sisteme eklediğinizdeki bakiye. Sonraki hareketler bu rakamın üstüne eklenir.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notlar</Label>
<Textarea
id="notes"
name="notes"
rows={3}
defaultValue={account?.notes ?? ""}
placeholder="Şube, yetkili, müşteri no, vb."
/>
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Kaydet"}
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,294 @@
"use client";
import { useState, useTransition } from "react";
import {
Archive,
ArchiveRestore,
Building2,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
archiveBankAccountAction,
deleteBankAccountAction,
} from "@/lib/appwrite/bank-account-actions";
import { formatTRY } from "@/lib/format";
import { ScopeBadge } from "@/components/finance/scope-toggle";
import { cn } from "@/lib/utils";
import { BankFormSheet } from "./bank-form-sheet";
import type { BankAccountRow } from "./types";
type Props = { accounts: BankAccountRow[] };
export function BanksClient({ accounts }: Props) {
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<BankAccountRow | null>(null);
const [deleting, setDeleting] = useState<BankAccountRow | null>(null);
const [busy, startTransition] = useTransition();
const active = accounts.filter((a) => !a.archived);
const archived = accounts.filter((a) => a.archived);
const totalBalance = active.reduce((s, a) => s + a.balance, 0);
const toggleArchive = (acc: BankAccountRow) => {
startTransition(async () => {
const fd = new FormData();
fd.set("id", acc.id);
const result = await archiveBankAccountAction(fd);
if (result.ok) {
toast.success(acc.archived ? "Hesap geri açıldı." : "Hesap arşivlendi.");
} else {
toast.error(result.error ?? "İşlem başarısız.");
}
});
};
const handleDelete = () => {
if (!deleting) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deleting.id);
const result = await deleteBankAccountAction(fd);
if (result.ok) {
toast.success("Hesap silindi.");
setDeleting(null);
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<Card className="flex-1">
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Toplam bakiye (aktif hesaplar)</p>
<p
className={cn(
"mt-1 text-2xl font-semibold tabular-nums",
totalBalance >= 0 ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400",
)}
>
{formatTRY(totalBalance)}
</p>
</CardContent>
</Card>
<Button
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
>
<Plus className="size-4" />
Yeni hesap
</Button>
</div>
{active.length === 0 && archived.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-2 py-12 text-center">
<Building2 className="text-muted-foreground size-8" />
<p className="text-sm">Henüz banka hesabı eklenmemiş.</p>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
>
<Plus className="size-3.5" />
İlk hesabı ekle
</Button>
</CardContent>
</Card>
) : (
<>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{active.map((a) => (
<AccountCard
key={a.id}
account={a}
onEdit={() => {
setEditing(a);
setFormOpen(true);
}}
onArchiveToggle={() => toggleArchive(a)}
onDelete={() => setDeleting(a)}
busy={busy}
/>
))}
</div>
{archived.length > 0 && (
<details className="group">
<summary className="text-muted-foreground hover:text-foreground cursor-pointer text-sm">
Arşivlenmiş hesaplar ({archived.length})
</summary>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{archived.map((a) => (
<AccountCard
key={a.id}
account={a}
onEdit={() => {
setEditing(a);
setFormOpen(true);
}}
onArchiveToggle={() => toggleArchive(a)}
onDelete={() => setDeleting(a)}
busy={busy}
/>
))}
</div>
</details>
)}
</>
)}
<BankFormSheet
open={formOpen}
onOpenChange={(v) => {
setFormOpen(v);
if (!v) setEditing(null);
}}
account={editing}
/>
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Hesabı sil</DialogTitle>
<DialogDescription>
<strong>{deleting?.bankName} {deleting?.accountName}</strong> kalıcı olarak silinecek.
Bağlı finans hareketi varsa silme reddedilir; o durumda arşivlemeyi tercih edin.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleting(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function AccountCard({
account,
onEdit,
onArchiveToggle,
onDelete,
busy,
}: {
account: BankAccountRow;
onEdit: () => void;
onArchiveToggle: () => void;
onDelete: () => void;
busy: boolean;
}) {
const positive = account.balance >= 0;
return (
<Card className={cn(account.archived && "opacity-60")}>
<CardContent className="space-y-3 p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Building2 className="text-muted-foreground size-4 shrink-0" />
<h3 className="truncate font-medium">{account.bankName}</h3>
{account.archived && (
<Badge variant="outline" className="text-[10px]">
Arşivli
</Badge>
)}
<ScopeBadge scope={account.scope} />
</div>
<p className="text-muted-foreground mt-0.5 truncate text-sm">{account.accountName}</p>
{account.iban && (
<p className="text-muted-foreground mt-1 truncate font-mono text-[11px]">
{account.iban.replace(/(.{4})/g, "$1 ").trim()}
</p>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8 shrink-0" disabled={busy}>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>
<Pencil className="size-3.5" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem onClick={onArchiveToggle}>
{account.archived ? (
<>
<ArchiveRestore className="size-3.5" />
Arşivden çıkar
</>
) : (
<>
<Archive className="size-3.5" />
Arşivle
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="size-3.5" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div>
<p className="text-muted-foreground text-xs">Güncel bakiye</p>
<p
className={cn(
"text-xl font-semibold tabular-nums",
positive ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400",
)}
>
{formatTRY(account.balance)}
</p>
{account.balance !== account.openingBalance && (
<p className="text-muted-foreground mt-0.5 text-[11px]">
Açılış: {formatTRY(account.openingBalance)}
</p>
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,11 @@
export type BankAccountRow = {
id: string;
bankName: string;
accountName: string;
iban: string;
openingBalance: number;
notes: string;
archived: boolean;
balance: number;
scope: "company" | "personal";
};
@@ -0,0 +1,53 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import {
getBankAccountBalances,
listBankAccounts,
} from "@/lib/appwrite/bank-account-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { BanksClient } from "./components/banks-client";
export const metadata: Metadata = {
title: "İşletmem — Banka hesapları",
};
export default async function BanksPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const [accounts, balances] = await Promise.all([
listBankAccounts(ctx.tenantId, ctx.user.id),
getBankAccountBalances(ctx.tenantId, ctx.user.id),
]);
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
<h1 className="text-2xl font-bold tracking-tight">Banka hesapları</h1>
<p className="text-muted-foreground text-sm">
İşletmenize ait banka hesaplarını ve güncel bakiyelerini takip edin.
</p>
</div>
<BanksClient
accounts={accounts.map((a) => ({
id: a.$id,
bankName: a.bankName,
accountName: a.accountName,
iban: a.iban ?? "",
openingBalance: a.openingBalance ?? 0,
notes: a.notes ?? "",
archived: Boolean(a.archived),
balance: balances.get(a.$id) ?? a.openingBalance ?? 0,
scope: (a.scope ?? "company") as "company" | "personal",
}))}
/>
</div>
);
}
@@ -0,0 +1,228 @@
"use client";
import { useActionState, useEffect } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import {
createCreditCardAction,
updateCreditCardAction,
} from "@/lib/appwrite/credit-card-actions";
import { initialCreditCardState } from "@/lib/appwrite/credit-card-types";
import { ScopeToggle } from "@/components/finance/scope-toggle";
import type { BankAccountOption, CreditCardRow } from "./types";
const NONE = "__none__";
export function CardFormSheet({
open,
onOpenChange,
card,
bankAccounts,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
card?: CreditCardRow | null;
bankAccounts: BankAccountOption[];
}) {
const isEdit = Boolean(card);
const action = isEdit ? updateCreditCardAction : createCreditCardAction;
const [state, formAction, isPending] = useActionState(action, initialCreditCardState);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Kart güncellendi." : "Kart eklendi.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Kartı düzenle" : "Yeni kredi kartı"}</SheetTitle>
<SheetDescription>
Hesap kesim ve son ödeme günleri her ay otomatik kullanılır. Ekstreler kart başına manuel girilir.
</SheetDescription>
</SheetHeader>
<form
action={(fd) => {
if (fd.get("bankAccountId") === NONE) fd.set("bankAccountId", "");
formAction(fd);
}}
className="flex flex-1 flex-col"
>
{isEdit && card && <input type="hidden" name="id" value={card.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<ScopeToggle defaultValue={card?.scope ?? "company"} />
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="bankName">Banka *</Label>
<Input
id="bankName"
name="bankName"
defaultValue={card?.bankName ?? ""}
required
placeholder="Garanti BBVA"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cardName">Kart adı *</Label>
<Input
id="cardName"
name="cardName"
defaultValue={card?.cardName ?? ""}
required
placeholder="Bonus / Maximum / World"
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="last4">Son 4 hane</Label>
<Input
id="last4"
name="last4"
defaultValue={card?.last4 ?? ""}
maxLength={4}
inputMode="numeric"
placeholder="1234"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="creditLimit">Kredi limiti ()</Label>
<Input
id="creditLimit"
name="creditLimit"
type="number"
step="0.01"
min="0"
defaultValue={card?.creditLimit ?? 0}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="statementDay">Hesap kesim günü</Label>
<Input
id="statementDay"
name="statementDay"
type="number"
min="1"
max="28"
defaultValue={card?.statementDay ?? 1}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dueDay">Son ödeme günü</Label>
<Input
id="dueDay"
name="dueDay"
type="number"
min="1"
max="28"
defaultValue={card?.dueDay ?? 10}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="interestRate">Aylık faiz %</Label>
<Input
id="interestRate"
name="interestRate"
type="number"
step="0.01"
min="0"
defaultValue={card?.interestRate ?? 4.25}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="bankAccountId">Bağlı hesap</Label>
<Select
name="bankAccountId"
defaultValue={card?.bankAccountId || NONE}
disabled={bankAccounts.length === 0}
>
<SelectTrigger id="bankAccountId">
<SelectValue placeholder={bankAccounts.length === 0 ? "Önce hesap ekleyin" : "Yok"} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Yok</SelectItem>
{bankAccounts.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Ekstre ödemeleri seçilen hesaba expense olarak yazılır.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notlar</Label>
<Textarea
id="notes"
name="notes"
rows={3}
defaultValue={card?.notes ?? ""}
placeholder="Sadakat puanı, kampanya, vb."
/>
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Kaydet"}
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,522 @@
"use client";
import { useState, useTransition } from "react";
import {
Archive,
ArchiveRestore,
Check,
CreditCard as CreditCardIcon,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
archiveCreditCardAction,
deleteCreditCardAction,
deleteStatementAction,
payStatementAction,
} from "@/lib/appwrite/credit-card-actions";
import { formatDate, formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
import { CardFormSheet } from "./card-form-sheet";
import { StatementFormSheet } from "./statement-form-sheet";
import {
type BankAccountOption,
type CreditCardRow,
STATEMENT_STATUS_COLOR,
STATEMENT_STATUS_LABEL,
type StatementRow,
} from "./types";
type Props = {
cards: CreditCardRow[];
statements: StatementRow[];
bankAccounts: BankAccountOption[];
};
export function CardsClient({ cards, statements, bankAccounts }: Props) {
const [cardFormOpen, setCardFormOpen] = useState(false);
const [editingCard, setEditingCard] = useState<CreditCardRow | null>(null);
const [deletingCard, setDeletingCard] = useState<CreditCardRow | null>(null);
const [stmtFormOpen, setStmtFormOpen] = useState(false);
const [stmtCard, setStmtCard] = useState<CreditCardRow | null>(null);
const [payDialog, setPayDialog] = useState<StatementRow | null>(null);
const [payAmount, setPayAmount] = useState("");
const [deletingStmt, setDeletingStmt] = useState<StatementRow | null>(null);
const [busy, startTransition] = useTransition();
const active = cards.filter((c) => !c.archived);
const archived = cards.filter((c) => c.archived);
const stmtsByCard = new Map<string, StatementRow[]>();
for (const s of statements) {
const arr = stmtsByCard.get(s.cardId) ?? [];
arr.push(s);
stmtsByCard.set(s.cardId, arr);
}
const totalOutstanding = statements
.filter((s) => s.status !== "paid")
.reduce((sum, s) => sum + (s.totalDebt - s.paidAmount), 0);
const overdueCount = statements.filter((s) => s.status === "overdue").length;
const toggleArchive = (c: CreditCardRow) => {
startTransition(async () => {
const fd = new FormData();
fd.set("id", c.id);
const r = await archiveCreditCardAction(fd);
if (r.ok) toast.success(c.archived ? "Kart geri açıldı." : "Kart arşivlendi.");
else toast.error(r.error ?? "İşlem başarısız.");
});
};
const handleDeleteCard = () => {
if (!deletingCard) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deletingCard.id);
const r = await deleteCreditCardAction(fd);
if (r.ok) {
toast.success("Kart silindi.");
setDeletingCard(null);
} else {
toast.error(r.error ?? "Silme başarısız.");
}
});
};
const handlePay = () => {
if (!payDialog) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", payDialog.id);
if (payAmount.trim()) fd.set("amount", payAmount);
const r = await payStatementAction(fd);
if (r.ok) {
toast.success("Ödeme kaydedildi.");
setPayDialog(null);
setPayAmount("");
} else {
toast.error(r.error ?? "Ödeme başarısız.");
}
});
};
const handleDeleteStmt = () => {
if (!deletingStmt) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deletingStmt.id);
const r = await deleteStatementAction(fd);
if (r.ok) {
toast.success("Ekstre silindi.");
setDeletingStmt(null);
} else {
toast.error(r.error ?? "Silme başarısız.");
}
});
};
return (
<div className="space-y-6">
<div className="grid gap-3 md:grid-cols-3">
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Aktif kart</p>
<p className="mt-1 text-2xl font-semibold">{active.length}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Bekleyen toplam borç</p>
<p className="mt-1 text-2xl font-semibold tabular-nums text-amber-600 dark:text-amber-400">
{formatTRY(totalOutstanding)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Vadesi geçmiş ekstre</p>
<p
className={cn(
"mt-1 text-2xl font-semibold",
overdueCount > 0 && "text-red-600 dark:text-red-400",
)}
>
{overdueCount}
</p>
</CardContent>
</Card>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setStmtCard(null);
setStmtFormOpen(true);
}}
disabled={cards.length === 0}
>
<Plus className="size-4" />
Yeni ekstre
</Button>
<Button
onClick={() => {
setEditingCard(null);
setCardFormOpen(true);
}}
>
<Plus className="size-4" />
Yeni kart
</Button>
</div>
{cards.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-2 py-12 text-center">
<CreditCardIcon className="text-muted-foreground size-8" />
<p className="text-sm">Henüz kredi kartı eklenmemiş.</p>
<Button variant="outline" size="sm" onClick={() => setCardFormOpen(true)}>
<Plus className="size-3.5" />
İlk kartı ekle
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{active.map((c) => {
const items = stmtsByCard.get(c.id) ?? [];
const totalDebt = items
.filter((s) => s.status !== "paid")
.reduce((sum, s) => sum + (s.totalDebt - s.paidAmount), 0);
return (
<Card key={c.id}>
<CardContent className="space-y-3 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<CreditCardIcon className="text-muted-foreground size-4" />
<h3 className="font-semibold">{c.bankName}</h3>
<span className="text-muted-foreground">·</span>
<span>{c.cardName}</span>
{c.last4 && (
<span className="text-muted-foreground font-mono text-xs">
**{c.last4}
</span>
)}
</div>
<div className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-xs">
<span>Limit {formatTRY(c.creditLimit)}</span>
<span>Kesim: ayın {c.statementDay}'i</span>
<span>Vade: ayın {c.dueDay}'i</span>
<span>Aylık faiz: %{c.interestRate}</span>
</div>
{c.bankAccountLabel && (
<p className="text-muted-foreground mt-1 text-xs">
Hesap: {c.bankAccountLabel}
</p>
)}
</div>
<div className="flex items-center gap-2">
<div className="text-right">
<p className="text-muted-foreground text-xs">Bekleyen</p>
<p className="font-semibold tabular-nums">{formatTRY(totalDebt)}</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8" disabled={busy}>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setStmtCard(c);
setStmtFormOpen(true);
}}
>
<Plus className="size-3.5" />
Ekstre ekle
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setEditingCard(c);
setCardFormOpen(true);
}}
>
<Pencil className="size-3.5" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem onClick={() => toggleArchive(c)}>
<Archive className="size-3.5" />
Arşivle
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setDeletingCard(c)}
>
<Trash2 className="size-3.5" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{items.length > 0 && (
<div className="border-t pt-3">
<Table>
<TableHeader>
<TableRow>
<TableHead>Dönem</TableHead>
<TableHead>Son ödeme</TableHead>
<TableHead className="text-right">Toplam</TableHead>
<TableHead className="text-right">Asgari</TableHead>
<TableHead className="text-right">Ödenen</TableHead>
<TableHead>Durum</TableHead>
<TableHead className="text-right">İşlem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((s) => {
const remaining = s.totalDebt - s.paidAmount;
return (
<TableRow key={s.id}>
<TableCell className="font-mono text-sm">{s.period}</TableCell>
<TableCell className="text-muted-foreground text-sm">
{formatDate(s.dueDate)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(s.totalDebt)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(s.minimumPayment)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(s.paidAmount)}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={cn("border-0", STATEMENT_STATUS_COLOR[s.status])}
>
{STATEMENT_STATUS_LABEL[s.status]}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{remaining > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => {
setPayDialog(s);
setPayAmount(remaining.toFixed(2));
}}
>
<Check className="size-3.5" />
Öde
</Button>
)}
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => setDeletingStmt(s)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
})}
{archived.length > 0 && (
<details className="group">
<summary className="text-muted-foreground hover:text-foreground cursor-pointer text-sm">
Arşivlenmiş kartlar ({archived.length})
</summary>
<div className="mt-4 space-y-3">
{archived.map((c) => (
<Card key={c.id} className="opacity-70">
<CardContent className="flex items-center justify-between p-4">
<div>
<p className="font-medium">
{c.bankName} {c.cardName}{" "}
{c.last4 && (
<span className="text-muted-foreground font-mono text-xs">
**{c.last4}
</span>
)}
</p>
<p className="text-muted-foreground text-xs">Arşivli</p>
</div>
<Button variant="outline" size="sm" onClick={() => toggleArchive(c)}>
<ArchiveRestore className="size-3.5" />
Geri
</Button>
</CardContent>
</Card>
))}
</div>
</details>
)}
</div>
)}
<CardFormSheet
open={cardFormOpen}
onOpenChange={(v) => {
setCardFormOpen(v);
if (!v) setEditingCard(null);
}}
card={editingCard}
bankAccounts={bankAccounts}
/>
<StatementFormSheet
open={stmtFormOpen}
onOpenChange={(v) => {
setStmtFormOpen(v);
if (!v) setStmtCard(null);
}}
card={stmtCard}
cards={cards}
/>
<Dialog open={Boolean(deletingCard)} onOpenChange={(v) => !v && setDeletingCard(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Kartı sil</DialogTitle>
<DialogDescription>
<strong>
{deletingCard?.bankName} {deletingCard?.cardName}
</strong>{" "}
ve tüm ekstreleri silinecek.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingCard(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDeleteCard} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(payDialog)}
onOpenChange={(v) => {
if (!v) {
setPayDialog(null);
setPayAmount("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Ekstre ödemesi</DialogTitle>
<DialogDescription>
{payDialog && (
<>
<strong>{payDialog.period}</strong> dönemi kalan{" "}
{formatTRY(payDialog.totalDebt - payDialog.paidAmount)}.
<br />
Tutarı boş bırakırsanız tamamı ödenir.
</>
)}
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-2">
<label className="text-sm font-medium">Ödenen tutar ()</label>
<Input
type="number"
step="0.01"
min="0"
value={payAmount}
onChange={(e) => setPayAmount(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPayDialog(null)} disabled={busy}>
Vazgeç
</Button>
<Button onClick={handlePay} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
Ödemeyi kaydet
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(deletingStmt)} onOpenChange={(v) => !v && setDeletingStmt(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ekstreyi sil</DialogTitle>
<DialogDescription>
<strong>{deletingStmt?.period}</strong> ekstresi silinecek. Bağlı gider kaydı varsa
o da silinir.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingStmt(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDeleteStmt} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,197 @@
"use client";
import { useActionState, useEffect, useMemo } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { createStatementAction } from "@/lib/appwrite/credit-card-actions";
import { initialCreditCardState } from "@/lib/appwrite/credit-card-types";
import type { CreditCardRow } from "./types";
function pad(n: number) {
return String(n).padStart(2, "0");
}
function defaultDates(card?: CreditCardRow | null) {
const now = new Date();
const sd = card?.statementDay ?? 1;
const dd = card?.dueDay ?? 10;
const statement = new Date(now.getFullYear(), now.getMonth(), Math.min(sd, 28));
const due = new Date(now.getFullYear(), now.getMonth(), Math.min(dd, 28));
if (due.getTime() < statement.getTime()) due.setMonth(due.getMonth() + 1);
const period = `${statement.getFullYear()}-${pad(statement.getMonth() + 1)}`;
const ymd = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
return { period, statementDate: ymd(statement), dueDate: ymd(due) };
}
export function StatementFormSheet({
open,
onOpenChange,
card,
cards,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
card?: CreditCardRow | null;
cards: CreditCardRow[];
}) {
const [state, formAction, isPending] = useActionState(
createStatementAction,
initialCreditCardState,
);
const defaults = useMemo(() => defaultDates(card), [card]);
useEffect(() => {
if (state.ok) {
toast.success("Ekstre kaydedildi.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>Yeni ekstre</SheetTitle>
<SheetDescription>
Banka ekstrenizdeki dönem, son ödeme tarihi, toplam borç ve asgari ödeme tutarını girin.
</SheetDescription>
</SheetHeader>
<form action={formAction} className="flex flex-1 flex-col">
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div className="grid gap-2">
<Label htmlFor="cardId">Kart *</Label>
<Select name="cardId" defaultValue={card?.id ?? ""}>
<SelectTrigger id="cardId">
<SelectValue placeholder="Kart seçin" />
</SelectTrigger>
<SelectContent>
{cards.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.bankName} {c.cardName} {c.last4 ? `**${c.last4}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
{state.fieldErrors?.cardId && (
<p className="text-destructive text-xs">{state.fieldErrors.cardId}</p>
)}
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="period">Dönem (YYYY-AA) *</Label>
<Input
id="period"
name="period"
defaultValue={defaults.period}
pattern="\d{4}-\d{2}"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="statementDate">Hesap kesim *</Label>
<Input
id="statementDate"
name="statementDate"
type="date"
defaultValue={defaults.statementDate}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dueDate">Son ödeme *</Label>
<Input
id="dueDate"
name="dueDate"
type="date"
defaultValue={defaults.dueDate}
required
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="totalDebt">Toplam borç () *</Label>
<Input
id="totalDebt"
name="totalDebt"
type="number"
step="0.01"
min="0"
required
placeholder="0.00"
/>
{state.fieldErrors?.totalDebt && (
<p className="text-destructive text-xs">{state.fieldErrors.totalDebt}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="minimumPayment">Asgari ödeme ()</Label>
<Input
id="minimumPayment"
name="minimumPayment"
type="number"
step="0.01"
min="0"
placeholder="0.00"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notlar</Label>
<Textarea id="notes" name="notes" rows={3} placeholder="Önemli ekstre notları" />
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
Kaydet
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,44 @@
export type CreditCardRow = {
id: string;
bankName: string;
cardName: string;
last4: string;
creditLimit: number;
statementDay: number;
dueDay: number;
interestRate: number;
bankAccountId: string;
bankAccountLabel: string;
archived: boolean;
notes: string;
scope: "company" | "personal";
};
export type StatementRow = {
id: string;
cardId: string;
period: string; // YYYY-MM
statementDate: string;
dueDate: string;
totalDebt: number;
minimumPayment: number;
paidAmount: number;
status: "pending" | "partial" | "paid" | "overdue";
notes: string;
};
export type BankAccountOption = { id: string; label: string };
export const STATEMENT_STATUS_LABEL: Record<StatementRow["status"], string> = {
pending: "Bekliyor",
partial: "Kısmi ödendi",
paid: "Ödendi",
overdue: "Gecikti",
};
export const STATEMENT_STATUS_COLOR: Record<StatementRow["status"], string> = {
pending: "bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30",
partial: "bg-amber-500/15 text-amber-800 dark:text-amber-300 border-amber-500/30",
paid: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
overdue: "bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30",
};
@@ -0,0 +1,81 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { listBankAccounts } from "@/lib/appwrite/bank-account-queries";
import {
listCreditCards,
listStatements,
} from "@/lib/appwrite/credit-card-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { CardsClient } from "./components/cards-client";
export const metadata: Metadata = {
title: "İşletmem — Kredi kartları",
};
export default async function CardsPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const [cards, statements, bankAccounts] = await Promise.all([
listCreditCards(ctx.tenantId, ctx.user.id),
listStatements(ctx.tenantId, ctx.user.id),
listBankAccounts(ctx.tenantId, ctx.user.id),
]);
const bankMap = new Map(
bankAccounts.map((b) => [b.$id, `${b.bankName}${b.accountName}`]),
);
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
<h1 className="text-2xl font-bold tracking-tight">Kredi kartları</h1>
<p className="text-muted-foreground text-sm">
Kartlarınızı ve aylık ekstrelerinizi takip edin. Ekstre ödendiğinde otomatik gider kaydı oluşur.
</p>
</div>
<CardsClient
cards={cards.map((c) => ({
id: c.$id,
bankName: c.bankName,
cardName: c.cardName,
last4: c.last4 ?? "",
creditLimit: c.creditLimit ?? 0,
statementDay: c.statementDay ?? 1,
dueDay: c.dueDay ?? 10,
interestRate: c.interestRate ?? 4.25,
bankAccountId: c.bankAccountId ?? "",
bankAccountLabel: c.bankAccountId ? bankMap.get(c.bankAccountId) ?? "" : "",
archived: Boolean(c.archived),
notes: c.notes ?? "",
scope: (c.scope ?? "company") as "company" | "personal",
}))}
statements={statements.map((s) => ({
id: s.$id,
cardId: s.cardId,
period: s.period,
statementDate: s.statementDate,
dueDate: s.dueDate,
totalDebt: s.totalDebt,
minimumPayment: s.minimumPayment ?? 0,
paidAmount: s.paidAmount ?? 0,
status: s.status ?? "pending",
notes: s.notes ?? "",
}))}
bankAccounts={bankAccounts
.filter((b) => !b.archived)
.map((b) => ({
id: b.$id,
label: `${b.bankName}${b.accountName}`,
}))}
/>
</div>
);
}
@@ -0,0 +1,436 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type SortingState,
} from "@tanstack/react-table";
import {
ArrowDownCircle,
ArrowUpCircle,
CircleAlert,
CircleDollarSign,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Search,
Trash2,
Wallet,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { deleteFinanceEntryAction } from "@/lib/appwrite/finance-actions";
import { formatDate, formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
import { FinanceFormSheet } from "./finance-form-sheet";
import {
type BankAccountOption,
type Customer,
type FinanceRow,
type FinanceType,
PAYMENT_METHOD_LABEL,
TYPE_COLOR,
TYPE_LABEL,
} from "./types";
type Props = {
entries: FinanceRow[];
customers: Customer[];
bankAccounts: BankAccountOption[];
};
function StatCard({
label,
amount,
icon: Icon,
tone,
}: {
label: string;
amount: number;
icon: typeof Wallet;
tone: "income" | "expense" | "receivable" | "debt" | "net";
}) {
const toneClass = {
income: "text-emerald-600 dark:text-emerald-400",
expense: "text-red-600 dark:text-red-400",
receivable: "text-blue-600 dark:text-blue-400",
debt: "text-amber-600 dark:text-amber-400",
net: amount >= 0 ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400",
}[tone];
return (
<Card>
<CardContent className="flex items-start justify-between p-4">
<div>
<p className="text-muted-foreground text-xs">{label}</p>
<p className={cn("mt-1 text-xl font-semibold", toneClass)}>{formatTRY(amount)}</p>
</div>
<Icon className={cn("size-5", toneClass)} />
</CardContent>
</Card>
);
}
export function FinanceClient({ entries, customers, bankAccounts }: Props) {
const [tab, setTab] = useState<FinanceType | "all">("all");
const [search, setSearch] = useState("");
const [sorting, setSorting] = useState<SortingState>([]);
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<FinanceRow | null>(null);
const [defaultType, setDefaultType] = useState<FinanceType>("income");
const [deleting, setDeleting] = useState<FinanceRow | null>(null);
const [busy, startTransition] = useTransition();
const stats = useMemo(() => {
let income = 0,
expense = 0,
receivable = 0,
debt = 0;
for (const e of entries) {
if (e.type === "income") income += e.amount;
else if (e.type === "expense") expense += e.amount;
else if (e.type === "receivable") receivable += e.amount;
else if (e.type === "debt") debt += e.amount;
}
return { income, expense, receivable, debt, net: income - expense };
}, [entries]);
const filtered = useMemo(
() => (tab === "all" ? entries : entries.filter((e) => e.type === tab)),
[entries, tab],
);
const columns = useMemo<ColumnDef<FinanceRow>[]>(
() => [
{
accessorKey: "type",
header: "Tür",
cell: ({ row }) => (
<Badge variant="outline" className={cn("border-0", TYPE_COLOR[row.original.type])}>
{TYPE_LABEL[row.original.type]}
</Badge>
),
},
{
accessorKey: "amount",
header: "Tutar",
cell: ({ row }) => {
const sign =
row.original.type === "income" || row.original.type === "receivable" ? "+" : "";
return (
<span className="font-medium tabular-nums">
{sign} {formatTRY(row.original.amount)}
</span>
);
},
},
{
accessorKey: "date",
header: "Tarih",
cell: ({ row }) => (
<span className="text-muted-foreground">{formatDate(row.original.date)}</span>
),
},
{
accessorKey: "customerName",
header: "Müşteri",
cell: ({ row }) =>
row.original.customerName ? (
<span>{row.original.customerName}</span>
) : (
<span className="text-muted-foreground"></span>
),
},
{
accessorKey: "paymentMethod",
header: "Ödeme",
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{PAYMENT_METHOD_LABEL[row.original.paymentMethod]}
</span>
),
},
{
accessorKey: "description",
header: "Açıklama",
cell: ({ row }) => (
<div className="flex max-w-[300px] items-center gap-2">
<span className="text-muted-foreground line-clamp-1 text-sm">
{row.original.description || "—"}
</span>
{row.original.invoiceId && (
<Badge variant="outline" className="shrink-0 text-[10px]">
Faturadan
</Badge>
)}
</div>
),
},
{
id: "actions",
cell: ({ row }) => (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setEditing(row.original);
setFormOpen(true);
}}
>
<Pencil className="size-3.5" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleting(row.original)}
>
<Trash2 className="size-3.5" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
],
[],
);
const table = useReactTable({
data: filtered,
columns,
state: { globalFilter: search, sorting },
onGlobalFilterChange: setSearch,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 25 } },
globalFilterFn: (row, _id, fv) => {
const v = String(fv).toLowerCase();
return [row.original.description, row.original.customerName, row.original.amount.toString()]
.join(" ")
.toLowerCase()
.includes(v);
},
});
const handleDelete = () => {
if (!deleting) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deleting.id);
const result = await deleteFinanceEntryAction(fd);
if (result.ok) {
toast.success("Kayıt silindi.");
setDeleting(null);
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
const openCreate = (type: FinanceType) => {
setEditing(null);
setDefaultType(type);
setFormOpen(true);
};
return (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
<StatCard
label="Gelir"
amount={stats.income}
icon={ArrowUpCircle}
tone="income"
/>
<StatCard
label="Gider"
amount={stats.expense}
icon={ArrowDownCircle}
tone="expense"
/>
<StatCard label="Net" amount={stats.net} icon={CircleDollarSign} tone="net" />
<StatCard
label="Alacaklar"
amount={stats.receivable}
icon={Wallet}
tone="receivable"
/>
<StatCard label="Borçlar" amount={stats.debt} icon={CircleAlert} tone="debt" />
</div>
<Card>
<CardContent className="p-0">
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<Select value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tümü</SelectItem>
<SelectItem value="income">Gelir</SelectItem>
<SelectItem value="expense">Gider</SelectItem>
<SelectItem value="receivable">Alacaklar</SelectItem>
<SelectItem value="debt">Borçlar</SelectItem>
</SelectContent>
</Select>
<div className="relative md:max-w-xs md:flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Açıklama, müşteri, tutar..."
className="pl-9"
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => openCreate("income")}>
<Plus className="size-3.5" />
Gelir
</Button>
<Button variant="outline" size="sm" onClick={() => openCreate("expense")}>
<Plus className="size-3.5" />
Gider
</Button>
<Button variant="outline" size="sm" onClick={() => openCreate("receivable")}>
<Plus className="size-3.5" />
Alacak
</Button>
<Button variant="outline" size="sm" onClick={() => openCreate("debt")}>
<Plus className="size-3.5" />
Borç
</Button>
</div>
</div>
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<TableHead key={h.id}>
{h.isPlaceholder
? null
: flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((r) => (
<TableRow key={r.id}>
{r.getVisibleCells().map((c) => (
<TableCell key={c.id}>
{flexRender(c.column.columnDef.cell, c.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-32 text-center">
<p className="text-muted-foreground text-sm">Kayıt yok.</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<FinanceFormSheet
open={formOpen}
onOpenChange={(v) => {
setFormOpen(v);
if (!v) setEditing(null);
}}
entry={editing}
defaultType={defaultType}
customers={customers}
bankAccounts={bankAccounts}
onRequestDelete={(e) => {
setFormOpen(false);
setDeleting(e);
}}
/>
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Kaydı sil</DialogTitle>
<DialogDescription>
{deleting && (
<>
<strong>{TYPE_LABEL[deleting.type]}</strong> {formatTRY(deleting.amount)} (
{formatDate(deleting.date)}) silinecek.
</>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleting(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,275 @@
"use client";
import { useActionState, useEffect, useState } from "react";
import { Loader2, Save, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import {
createFinanceEntryAction,
updateFinanceEntryAction,
} from "@/lib/appwrite/finance-actions";
import { initialFinanceState } from "@/lib/appwrite/finance-types";
import { ScopeToggle } from "@/components/finance/scope-toggle";
import type { BankAccountOption, Customer, FinanceRow, FinanceType } from "./types";
const NONE = "__none__";
type Props = {
open: boolean;
onOpenChange: (v: boolean) => void;
entry?: FinanceRow | null;
defaultType?: FinanceType;
customers: Customer[];
bankAccounts: BankAccountOption[];
onRequestDelete?: (entry: FinanceRow) => void;
};
function isoToDate(iso: string): string {
if (!iso) return "";
return iso.slice(0, 10);
}
export function FinanceFormSheet({
open,
onOpenChange,
entry,
defaultType = "income",
customers,
bankAccounts,
onRequestDelete,
}: Props) {
const isEdit = Boolean(entry);
const action = isEdit ? updateFinanceEntryAction : createFinanceEntryAction;
const [state, formAction, isPending] = useActionState(action, initialFinanceState);
const [planLimitOpen, setPlanLimitOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Kayıt güncellendi." : "Kayıt eklendi.");
onOpenChange(false);
} else if (state.code === "PLAN_LIMIT_EXCEEDED") {
setPlanLimitOpen(true);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
const today = new Date().toISOString().slice(0, 10);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Kaydı düzenle" : "Yeni kayıt"}</SheetTitle>
<SheetDescription>
Gelir, gider, borç veya alacak girişi. Borç = ödeyeceğiniz, Alacak = tahsil edeceğiniz.
</SheetDescription>
</SheetHeader>
<form
action={(fd) => {
["customerId", "paymentMethod", "bankAccountId"].forEach((k) => {
if (fd.get(k) === NONE) fd.set(k, "");
});
formAction(fd);
}}
className="flex flex-1 flex-col"
>
{isEdit && entry && <input type="hidden" name="id" value={entry.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<ScopeToggle
defaultValue={(entry as { scope?: "company" | "personal" } | null | undefined)?.scope ?? "company"}
/>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="type">Tür *</Label>
<Select name="type" defaultValue={entry?.type ?? defaultType}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="income">Gelir</SelectItem>
<SelectItem value="expense">Gider</SelectItem>
<SelectItem value="receivable">Alacak</SelectItem>
<SelectItem value="debt">Borç</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="amount">Tutar () *</Label>
<Input
id="amount"
name="amount"
type="number"
step="0.01"
min="0.01"
defaultValue={entry?.amount ?? ""}
placeholder="0.00"
required
/>
{state.fieldErrors?.amount && (
<p className="text-destructive text-xs">{state.fieldErrors.amount}</p>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="date">Tarih *</Label>
<Input
id="date"
name="date"
type="date"
defaultValue={isoToDate(entry?.date ?? today)}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="paymentMethod">Ödeme yöntemi</Label>
<Select
name="paymentMethod"
defaultValue={entry?.paymentMethod || NONE}
>
<SelectTrigger id="paymentMethod">
<SelectValue placeholder="Belirtilmemiş" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Belirtilmemiş</SelectItem>
<SelectItem value="cash">Nakit</SelectItem>
<SelectItem value="transfer">Havale / EFT</SelectItem>
<SelectItem value="card">Kart</SelectItem>
<SelectItem value="check">Çek</SelectItem>
<SelectItem value="other">Diğer</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="customerId">Müşteri (opsiyonel)</Label>
<Select name="customerId" defaultValue={entry?.customerId || NONE}>
<SelectTrigger id="customerId">
<SelectValue placeholder="Yok" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Yok</SelectItem>
{customers.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="bankAccountId">Banka hesabı</Label>
<Select
name="bankAccountId"
defaultValue={entry?.bankAccountId || NONE}
disabled={bankAccounts.length === 0}
>
<SelectTrigger id="bankAccountId">
<SelectValue placeholder={bankAccounts.length === 0 ? "Önce hesap ekleyin" : "Yok"} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Yok</SelectItem>
{bankAccounts.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Açıklama</Label>
<Textarea
id="description"
name="description"
rows={3}
defaultValue={entry?.description ?? ""}
placeholder="Hangi kalem, hangi fatura, vb."
/>
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full items-center justify-between gap-2">
<div>
{isEdit && entry && onRequestDelete && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => onRequestDelete(entry)}
disabled={isPending}
>
<Trash2 className="size-3.5" />
Sil
</Button>
)}
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Kaydet"}
</>
)}
</Button>
</div>
</div>
</SheetFooter>
</form>
</SheetContent>
<PlanLimitDialog
open={planLimitOpen}
onOpenChange={setPlanLimitOpen}
message={state.error}
/>
</Sheet>
);
}
@@ -0,0 +1,42 @@
export type FinanceType = "income" | "expense" | "debt" | "receivable";
export type PaymentMethod = "cash" | "transfer" | "card" | "check" | "other" | "";
export type FinanceRow = {
id: string;
type: FinanceType;
amount: number;
date: string;
description: string;
customerId: string;
customerName: string;
paymentMethod: PaymentMethod;
invoiceId: string;
bankAccountId: string;
bankAccountLabel: string;
};
export type Customer = { id: string; name: string };
export type BankAccountOption = { id: string; label: string };
export const TYPE_LABEL: Record<FinanceType, string> = {
income: "Gelir",
expense: "Gider",
debt: "Borç",
receivable: "Alacak",
};
export const TYPE_COLOR: Record<FinanceType, string> = {
income: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
expense: "bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30",
debt: "bg-amber-500/15 text-amber-800 dark:text-amber-300 border-amber-500/30",
receivable: "bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30",
};
export const PAYMENT_METHOD_LABEL: Record<PaymentMethod, string> = {
cash: "Nakit",
transfer: "Havale / EFT",
card: "Kart",
check: "Çek",
other: "Diğer",
"": "—",
};
@@ -0,0 +1,257 @@
"use client";
import { useActionState, useEffect, useState } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { createLoanAction } from "@/lib/appwrite/loan-actions";
import { initialLoanState } from "@/lib/appwrite/loan-types";
import { formatTRY } from "@/lib/format";
import { ScopeToggle } from "@/components/finance/scope-toggle";
import type { BankAccountOption } from "./types";
const NONE = "__none__";
function computeMonthly(principal: number, ratePct: number, n: number): number {
if (!principal || !n) return 0;
const r = ratePct / 100;
if (r === 0) return Number((principal / n).toFixed(2));
const factor = Math.pow(1 + r, n);
return Number(((principal * r * factor) / (factor - 1)).toFixed(2));
}
export function LoanFormSheet({
open,
onOpenChange,
bankAccounts,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
bankAccounts: BankAccountOption[];
}) {
const [state, formAction, isPending] = useActionState(createLoanAction, initialLoanState);
const [principal, setPrincipal] = useState(0);
const [rate, setRate] = useState(2.5);
const [term, setTerm] = useState(24);
useEffect(() => {
if (state.ok) {
toast.success("Kredi kaydedildi, taksitler oluşturuldu.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
const monthly = computeMonthly(principal, rate, term);
const total = monthly * term;
const today = new Date().toISOString().slice(0, 10);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>Yeni kredi</SheetTitle>
<SheetDescription>
Kaydedince {term || 0} adet taksit otomatik hesaplanır ve eklenir.
</SheetDescription>
</SheetHeader>
<form
action={(fd) => {
if (fd.get("bankAccountId") === NONE) fd.set("bankAccountId", "");
formAction(fd);
}}
className="flex flex-1 flex-col"
>
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<ScopeToggle />
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="bankName">Banka *</Label>
<Input id="bankName" name="bankName" required placeholder="Garanti BBVA" />
{state.fieldErrors?.bankName && (
<p className="text-destructive text-xs">{state.fieldErrors.bankName}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="loanType">Tür</Label>
<Select name="loanType" defaultValue="consumer">
<SelectTrigger id="loanType">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="consumer">İhtiyaç</SelectItem>
<SelectItem value="vehicle">Taşıt</SelectItem>
<SelectItem value="housing">Konut</SelectItem>
<SelectItem value="commercial">Ticari</SelectItem>
<SelectItem value="kmh">KMH</SelectItem>
<SelectItem value="other">Diğer</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="loanName">Kredi adı *</Label>
<Input id="loanName" name="loanName" required placeholder="Örn. Ofis kredisi" />
</div>
<div className="grid gap-2">
<Label htmlFor="bankAccountId">Bağlı hesap</Label>
<Select name="bankAccountId" defaultValue={NONE} disabled={bankAccounts.length === 0}>
<SelectTrigger id="bankAccountId">
<SelectValue placeholder={bankAccounts.length === 0 ? "Önce hesap ekleyin" : "Yok"} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Yok</SelectItem>
{bankAccounts.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Taksit ödemeleri seçilen hesaba expense olarak yazılır.
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="principal">Anapara () *</Label>
<Input
id="principal"
name="principal"
type="number"
step="0.01"
min="0.01"
required
value={principal || ""}
onChange={(e) => setPrincipal(Number(e.target.value) || 0)}
/>
{state.fieldErrors?.principal && (
<p className="text-destructive text-xs">{state.fieldErrors.principal}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="interestRate">Aylık faiz %</Label>
<Input
id="interestRate"
name="interestRate"
type="number"
step="0.01"
min="0"
max="100"
required
value={rate}
onChange={(e) => setRate(Number(e.target.value) || 0)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="termMonths">Vade (ay) *</Label>
<Input
id="termMonths"
name="termMonths"
type="number"
min="1"
max="480"
required
value={term}
onChange={(e) => setTerm(Number(e.target.value) || 0)}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="startDate">Başlangıç *</Label>
<Input
id="startDate"
name="startDate"
type="date"
defaultValue={today}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="paymentDay">Ödeme günü (1-28)</Label>
<Input
id="paymentDay"
name="paymentDay"
type="number"
min="1"
max="28"
defaultValue={1}
/>
</div>
</div>
<div className="bg-muted/40 rounded-md border p-3 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Aylık taksit</span>
<span className="font-medium tabular-nums">{formatTRY(monthly)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Toplam ödeme</span>
<span className="font-medium tabular-nums">{formatTRY(total)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Toplam faiz</span>
<span className="tabular-nums">{formatTRY(Math.max(0, total - principal))}</span>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notlar</Label>
<Textarea id="notes" name="notes" rows={3} placeholder="Sözleşme no, kefiller, vb." />
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Oluşturuluyor...
</>
) : (
<>
<Save className="size-4" />
Krediyi kaydet
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,357 @@
"use client";
import { useState, useTransition } from "react";
import {
Banknote,
Check,
ChevronDown,
ChevronUp,
Loader2,
Plus,
RotateCcw,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
deleteLoanAction,
payInstallmentAction,
unpayInstallmentAction,
} from "@/lib/appwrite/loan-actions";
import { formatDate, formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
import { LoanFormSheet } from "./loan-form-sheet";
import {
type BankAccountOption,
type InstallmentRow,
LOAN_STATUS_LABEL,
LOAN_TYPE_LABEL,
type LoanRow,
} from "./types";
type Props = {
loans: LoanRow[];
installments: InstallmentRow[];
bankAccounts: BankAccountOption[];
};
export function LoansClient({ loans, installments, bankAccounts }: Props) {
const [formOpen, setFormOpen] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
const [deleting, setDeleting] = useState<LoanRow | null>(null);
const [busy, startTransition] = useTransition();
const totalPrincipal = loans
.filter((l) => l.status === "active")
.reduce((s, l) => s + l.principal, 0);
const totalRemaining = loans
.filter((l) => l.status === "active")
.reduce((s, l) => s + (l.totalAmount - l.paidAmount), 0);
const installmentsByLoan = new Map<string, InstallmentRow[]>();
for (const i of installments) {
const arr = installmentsByLoan.get(i.loanId) ?? [];
arr.push(i);
installmentsByLoan.set(i.loanId, arr);
}
const togglePay = (inst: InstallmentRow) => {
startTransition(async () => {
const fd = new FormData();
fd.set("id", inst.id);
const result = inst.paid ? await unpayInstallmentAction(fd) : await payInstallmentAction(fd);
if (result.ok) {
toast.success(inst.paid ? "Taksit ödenmedi olarak işaretlendi." : "Taksit ödendi olarak işaretlendi.");
} else {
toast.error(result.error ?? "İşlem başarısız.");
}
});
};
const handleDelete = () => {
if (!deleting) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deleting.id);
const result = await deleteLoanAction(fd);
if (result.ok) {
toast.success("Kredi silindi.");
setDeleting(null);
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
return (
<div className="space-y-6">
<div className="grid gap-3 md:grid-cols-3">
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Aktif kredi sayısı</p>
<p className="mt-1 text-2xl font-semibold">
{loans.filter((l) => l.status === "active").length}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Toplam çekilen</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatTRY(totalPrincipal)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Kalan ödeme</p>
<p className="mt-1 text-2xl font-semibold tabular-nums text-amber-600 dark:text-amber-400">
{formatTRY(totalRemaining)}
</p>
</CardContent>
</Card>
</div>
<div className="flex justify-end">
<Button onClick={() => setFormOpen(true)}>
<Plus className="size-4" />
Yeni kredi
</Button>
</div>
{loans.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-2 py-12 text-center">
<Banknote className="text-muted-foreground size-8" />
<p className="text-sm">Henüz kredi tanımlanmamış.</p>
<Button variant="outline" size="sm" onClick={() => setFormOpen(true)}>
<Plus className="size-3.5" />
İlk krediyi ekle
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{loans.map((loan) => {
const isOpen = expanded === loan.id;
const items = installmentsByLoan.get(loan.id) ?? [];
const progressPct =
loan.totalAmount > 0 ? (loan.paidAmount / loan.totalAmount) * 100 : 0;
return (
<Card key={loan.id}>
<CardContent className="space-y-3 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold">{loan.bankName}</h3>
<span className="text-muted-foreground">·</span>
<span className="text-sm">{loan.loanName}</span>
<Badge variant="outline" className="text-[10px]">
{LOAN_TYPE_LABEL[loan.loanType]}
</Badge>
<Badge
variant="outline"
className={cn(
"text-[10px]",
loan.status === "active"
? "border-blue-500/30 bg-blue-500/15 text-blue-700 dark:text-blue-300"
: loan.status === "closed"
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300"
: "border-red-500/30 bg-red-500/15 text-red-700 dark:text-red-300",
)}
>
{LOAN_STATUS_LABEL[loan.status]}
</Badge>
</div>
{loan.bankAccountLabel && (
<p className="text-muted-foreground mt-1 text-xs">
Hesap: {loan.bankAccountLabel}
</p>
)}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded(isOpen ? null : loan.id)}
>
{isOpen ? (
<>
<ChevronUp className="size-3.5" />
Kapat
</>
) : (
<>
<ChevronDown className="size-3.5" />
Taksitler ({items.length})
</>
)}
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setDeleting(loan)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
<div className="grid gap-2 md:grid-cols-4">
<Stat label="Anapara" value={formatTRY(loan.principal)} />
<Stat label="Aylık taksit" value={formatTRY(loan.monthlyPayment)} />
<Stat label="Aylık faiz" value={`%${loan.interestRate}`} />
<Stat
label="Sonraki ödeme"
value={loan.nextDue ? formatDate(loan.nextDue) : "—"}
/>
</div>
<div>
<div className="text-muted-foreground flex items-center justify-between text-xs">
<span>
{loan.remainingCount === 0
? "Tüm taksitler ödendi"
: `${loan.remainingCount} taksit kaldı`}
</span>
<span>
{formatTRY(loan.paidAmount)} / {formatTRY(loan.totalAmount)}
</span>
</div>
<div className="bg-muted mt-1 h-1.5 overflow-hidden rounded-full">
<div
className="bg-emerald-500 h-full"
style={{ width: `${Math.min(100, progressPct)}%` }}
/>
</div>
</div>
{isOpen && (
<div className="border-t pt-3">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">#</TableHead>
<TableHead>Vade</TableHead>
<TableHead className="text-right">Anapara</TableHead>
<TableHead className="text-right">Faiz</TableHead>
<TableHead className="text-right">Toplam</TableHead>
<TableHead className="text-right">Durum</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((it) => {
const overdue =
!it.paid && new Date(it.dueDate) < new Date();
return (
<TableRow
key={it.id}
className={cn(it.paid && "opacity-60")}
>
<TableCell className="font-mono">{it.installmentNo}</TableCell>
<TableCell
className={cn(
"text-muted-foreground text-sm",
overdue && "text-destructive font-medium",
)}
>
{formatDate(it.dueDate)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(it.principalPart)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(it.interestPart)}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatTRY(it.amount)}
</TableCell>
<TableCell className="text-right">
<Button
variant={it.paid ? "ghost" : "outline"}
size="sm"
disabled={busy}
onClick={() => togglePay(it)}
>
{busy ? (
<Loader2 className="size-3.5 animate-spin" />
) : it.paid ? (
<>
<RotateCcw className="size-3.5" />
Geri al
</>
) : (
<>
<Check className="size-3.5" />
Ödendi
</>
)}
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
)}
<LoanFormSheet open={formOpen} onOpenChange={setFormOpen} bankAccounts={bankAccounts} />
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Krediyi sil</DialogTitle>
<DialogDescription>
<strong>{deleting?.bankName} {deleting?.loanName}</strong> ve tüm taksitleri silinecek.
Bu işlem geri alınamaz.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleting(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-muted-foreground text-[10px] uppercase tracking-wide">{label}</p>
<p className="mt-0.5 text-sm font-medium tabular-nums">{value}</p>
</div>
);
}
@@ -0,0 +1,50 @@
export type LoanRow = {
id: string;
bankName: string;
loanName: string;
loanType: "consumer" | "vehicle" | "housing" | "commercial" | "kmh" | "other";
principal: number;
interestRate: number;
termMonths: number;
monthlyPayment: number;
startDate: string;
paymentDay: number;
status: "active" | "closed" | "defaulted";
bankAccountId: string;
bankAccountLabel: string;
notes: string;
totalAmount: number;
paidAmount: number;
remainingCount: number;
nextDue: string | null;
scope: "company" | "personal";
};
export type InstallmentRow = {
id: string;
loanId: string;
installmentNo: number;
dueDate: string;
amount: number;
principalPart: number;
interestPart: number;
paid: boolean;
paidAt: string;
};
export type BankAccountOption = { id: string; label: string };
export const LOAN_TYPE_LABEL: Record<LoanRow["loanType"], string> = {
consumer: "İhtiyaç",
vehicle: "Taşıt",
housing: "Konut",
commercial: "Ticari",
kmh: "KMH",
other: "Diğer",
};
export const LOAN_STATUS_LABEL: Record<LoanRow["status"], string> = {
active: "Aktif",
closed: "Kapalı",
defaulted: "Temerrüt",
};
+116
View File
@@ -0,0 +1,116 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { listBankAccounts } from "@/lib/appwrite/bank-account-queries";
import { listAllInstallments, listLoans } from "@/lib/appwrite/loan-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { LoansClient } from "./components/loans-client";
export const metadata: Metadata = {
title: "İşletmem — Krediler",
};
export default async function LoansPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const [loans, installments, bankAccounts] = await Promise.all([
listLoans(ctx.tenantId, ctx.user.id),
listAllInstallments(ctx.tenantId, ctx.user.id),
listBankAccounts(ctx.tenantId, ctx.user.id),
]);
const bankMap = new Map(
bankAccounts.map((b) => [b.$id, `${b.bankName}${b.accountName}`]),
);
// Aggregate installment metrics per loan
const byLoan = new Map<
string,
{ totalAmount: number; paidAmount: number; nextDue: string | null; remainingCount: number }
>();
for (const inst of installments) {
const cur = byLoan.get(inst.loanId) ?? {
totalAmount: 0,
paidAmount: 0,
nextDue: null,
remainingCount: 0,
};
cur.totalAmount += inst.amount ?? 0;
if (inst.paid) {
cur.paidAmount += inst.amount ?? 0;
} else {
cur.remainingCount += 1;
if (!cur.nextDue || new Date(inst.dueDate).getTime() < new Date(cur.nextDue).getTime()) {
cur.nextDue = inst.dueDate;
}
}
byLoan.set(inst.loanId, cur);
}
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
<h1 className="text-2xl font-bold tracking-tight">Krediler</h1>
<p className="text-muted-foreground text-sm">
Banka kredilerinizi ve taksit planlarını takip edin. Taksit ödendiğinde otomatik gider
kaydı oluşur.
</p>
</div>
<LoansClient
loans={loans.map((l) => {
const m = byLoan.get(l.$id) ?? {
totalAmount: 0,
paidAmount: 0,
nextDue: null,
remainingCount: 0,
};
return {
id: l.$id,
bankName: l.bankName,
loanName: l.loanName,
loanType: l.loanType ?? "consumer",
principal: l.principal,
interestRate: l.interestRate,
termMonths: l.termMonths,
monthlyPayment: l.monthlyPayment ?? 0,
startDate: l.startDate,
paymentDay: l.paymentDay ?? 1,
status: l.status ?? "active",
bankAccountId: l.bankAccountId ?? "",
bankAccountLabel: l.bankAccountId ? bankMap.get(l.bankAccountId) ?? "" : "",
notes: l.notes ?? "",
totalAmount: m.totalAmount,
paidAmount: m.paidAmount,
remainingCount: m.remainingCount,
nextDue: m.nextDue,
scope: (l.scope ?? "company") as "company" | "personal",
};
})}
installments={installments.map((i) => ({
id: i.$id,
loanId: i.loanId,
installmentNo: i.installmentNo,
dueDate: i.dueDate,
amount: i.amount,
principalPart: i.principalPart ?? 0,
interestPart: i.interestPart ?? 0,
paid: Boolean(i.paid),
paidAt: i.paidAt ?? "",
}))}
bankAccounts={bankAccounts
.filter((b) => !b.archived)
.map((b) => ({
id: b.$id,
label: `${b.bankName}${b.accountName}`,
}))}
/>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { listBankAccounts } from "@/lib/appwrite/bank-account-queries";
import { listCustomers } from "@/lib/appwrite/customer-queries";
import { listFinanceEntries } from "@/lib/appwrite/finance-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { FinanceClient } from "./components/finance-client";
export const metadata: Metadata = {
title: "İşletmem — Gelir / Gider",
};
export default async function FinancePage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const [entries, customers, bankAccounts] = await Promise.all([
listFinanceEntries(ctx.tenantId, ctx.user.id),
listCustomers(ctx.tenantId),
listBankAccounts(ctx.tenantId, ctx.user.id),
]);
const customerMap = new Map(customers.map((c) => [c.$id, c.name]));
const bankMap = new Map(
bankAccounts.map((b) => [b.$id, `${b.bankName}${b.accountName}`]),
);
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
<h1 className="text-2xl font-bold tracking-tight">Gelir / Gider</h1>
<p className="text-muted-foreground text-sm">
Nakit hareketleri, borç ve alacaklarınızı tek yerden takip edin.
</p>
</div>
<FinanceClient
entries={entries.map((e) => ({
id: e.$id,
type: e.type,
amount: e.amount,
date: e.date,
description: e.description ?? "",
customerId: e.customerId ?? "",
customerName: e.customerId ? customerMap.get(e.customerId) ?? "" : "",
paymentMethod: e.paymentMethod ?? "",
invoiceId: e.invoiceId ?? "",
bankAccountId: e.bankAccountId ?? "",
bankAccountLabel: e.bankAccountId ? bankMap.get(e.bankAccountId) ?? "" : "",
}))}
customers={customers.map((c) => ({ id: c.$id, name: c.name }))}
bankAccounts={bankAccounts
.filter((b) => !b.archived)
.map((b) => ({
id: b.$id,
label: `${b.bankName}${b.accountName}`,
}))}
/>
</div>
);
}
@@ -0,0 +1,542 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
AlertCircle,
ArrowDownRight,
ArrowUpRight,
Banknote,
Building2,
CircleDollarSign,
CreditCard,
Crown,
ExternalLink,
Receipt,
Wallet,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { FinancialReport, ReportPeriod } from "@/lib/appwrite/finance-report-queries";
import { formatDate, formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
const PERIOD_LABEL: Record<ReportPeriod, string> = {
month: "Bu ay",
quarter: "Bu çeyrek",
year: "Bu yıl",
all: "Tüm zamanlar",
};
const STATUS_LABEL: Record<"pending" | "partial" | "overdue", string> = {
pending: "Bekliyor",
partial: "Kısmi",
overdue: "Gecikti",
};
const STATUS_COLOR: Record<"pending" | "partial" | "overdue", string> = {
pending: "bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30",
partial: "bg-amber-500/15 text-amber-800 dark:text-amber-300 border-amber-500/30",
overdue: "bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30",
};
export function ReportClient({ data }: { data: FinancialReport }) {
const router = useRouter();
const setPeriod = (p: ReportPeriod) => {
const params = new URLSearchParams();
if (p !== "month") params.set("period", p);
router.push(`/finance/reports${params.size ? `?${params}` : ""}`);
};
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<Select value={data.period} onValueChange={(v) => setPeriod(v as ReportPeriod)}>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="month">{PERIOD_LABEL.month}</SelectItem>
<SelectItem value="quarter">{PERIOD_LABEL.quarter}</SelectItem>
<SelectItem value="year">{PERIOD_LABEL.year}</SelectItem>
<SelectItem value="all">{PERIOD_LABEL.all}</SelectItem>
</SelectContent>
</Select>
</div>
{/* KPIs */}
<div className="grid gap-4 md:grid-cols-4">
<KpiCard
label="Nakit pozisyonu"
value={formatTRY(data.kpi.cashPosition)}
tone={data.kpi.cashPosition >= 0 ? "positive" : "negative"}
icon={CircleDollarSign}
subtitle="Banka + alacaklar borçlar"
/>
<KpiCard
label={`${PERIOD_LABEL[data.period]} geliri`}
value={formatTRY(data.kpi.income)}
tone="positive"
icon={ArrowUpRight}
/>
<KpiCard
label={`${PERIOD_LABEL[data.period]} gideri`}
value={formatTRY(data.kpi.expense)}
tone="negative"
icon={ArrowDownRight}
/>
<KpiCard
label="Net"
value={formatTRY(data.kpi.net)}
tone={data.kpi.net >= 0 ? "positive" : "negative"}
icon={Wallet}
/>
</div>
{/* Cash composition */}
<Card>
<CardHeader>
<CardTitle>Nakit pozisyonu detayı</CardTitle>
<CardDescription>
Bugünkü gerçek nakit + tahsil edilebilir ödenecek borçlar
</CardDescription>
</CardHeader>
<CardContent>
<CompositionRow
icon={Building2}
label="Banka hesapları"
sign="+"
amount={data.composition.bankBalances}
href="/finance/banks"
/>
<CompositionRow
icon={Receipt}
label="Bekleyen tahsilatlar"
sign="+"
amount={data.composition.receivables}
href="/invoices"
/>
<CompositionRow
icon={Banknote}
label="Kredi kalan ödemeler"
sign=""
amount={data.composition.loanRemaining}
href="/finance/loans"
/>
<CompositionRow
icon={CreditCard}
label="Kart ekstre borçları"
sign=""
amount={data.composition.cardOutstanding}
href="/finance/cards"
/>
<div className="mt-3 flex items-center justify-between border-t pt-3">
<span className="text-sm font-semibold">Net pozisyon</span>
<span
className={cn(
"text-lg font-semibold tabular-nums",
data.kpi.cashPosition >= 0
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400",
)}
>
{formatTRY(data.kpi.cashPosition)}
</span>
</div>
</CardContent>
</Card>
{/* Trend chart */}
<TrendChartLazy data={data.trend} />
{/* Top customers + Expense breakdown */}
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Crown className="size-4" />
En çok ciro yapan müşteriler
</CardTitle>
<CardDescription>
{PERIOD_LABEL[data.period]} ödenmiş faturalara göre
</CardDescription>
</CardHeader>
<CardContent>
{data.topCustomers.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Bu dönemde ödenmiş fatura yok.
</p>
) : (
<ul className="space-y-3">
{data.topCustomers.map((c, i) => {
const max = data.topCustomers[0]?.total ?? 1;
const w = (c.total / max) * 100;
return (
<li key={c.name + i} className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-sm">
<span className="text-muted-foreground mr-2 tabular-nums">
{String(i + 1).padStart(2, "0")}
</span>
{c.name}
</span>
<span className="text-sm tabular-nums">{formatTRY(c.total)}</span>
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div
className="bg-emerald-500 h-full"
style={{ width: `${w}%` }}
/>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Gider dağılımı</CardTitle>
<CardDescription>{PERIOD_LABEL[data.period]} kaynak bazında</CardDescription>
</CardHeader>
<CardContent>
<ExpenseRow
label="Kredi taksit ödemeleri"
amount={data.expenseBreakdown.loans}
total={data.kpi.expense}
color="bg-amber-500"
/>
<ExpenseRow
label="Kredi kartı ödemeleri"
amount={data.expenseBreakdown.cards}
total={data.kpi.expense}
color="bg-violet-500"
/>
<ExpenseRow
label="Diğer (manuel) gider"
amount={data.expenseBreakdown.other}
total={data.kpi.expense}
color="bg-red-500"
/>
{data.kpi.expense === 0 && (
<p className="text-muted-foreground py-2 text-center text-sm">
Bu dönemde gider yok.
</p>
)}
</CardContent>
</Card>
</div>
{/* Loans + Cards summary */}
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Banknote className="size-4" />
Aktif krediler
</CardTitle>
<CardDescription>Kalan ödeme tutarına göre</CardDescription>
</div>
<Link
href="/finance/loans"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
Tümü <ExternalLink className="size-3" />
</Link>
</CardHeader>
<CardContent>
{data.loans.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Aktif kredi yok.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Kredi</TableHead>
<TableHead className="text-right">Aylık</TableHead>
<TableHead className="text-right">Kalan</TableHead>
<TableHead>Sonraki</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.loans.map((l) => (
<TableRow key={l.id}>
<TableCell>
<span className="block font-medium">{l.bankName}</span>
<span className="text-muted-foreground text-xs">{l.loanName}</span>
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(l.monthlyPayment)}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatTRY(l.remaining)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{l.nextDue ? formatDate(l.nextDue) : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<CreditCard className="size-4" />
Kart ekstreleri
</CardTitle>
<CardDescription>Bekleyen ve gecikmiş ödemeler</CardDescription>
</div>
<Link
href="/finance/cards"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
Tümü <ExternalLink className="size-3" />
</Link>
</CardHeader>
<CardContent>
{data.cardStatements.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Açık ekstre yok.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Kart</TableHead>
<TableHead>Vade</TableHead>
<TableHead className="text-right">Kalan</TableHead>
<TableHead>Durum</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.cardStatements.map((s) => (
<TableRow key={s.id}>
<TableCell>
<span className="block text-sm font-medium">{s.cardLabel}</span>
<span className="text-muted-foreground font-mono text-xs">
{s.period}
</span>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{formatDate(s.dueDate)}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatTRY(s.remaining)}
</TableCell>
<TableCell>
<Badge variant="outline" className={cn("border-0", STATUS_COLOR[s.status])}>
{STATUS_LABEL[s.status]}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
{/* Outstanding invoices */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Receipt className="size-4" />
Bekleyen faturalar
</CardTitle>
<CardDescription>
Tahsil edilmesi gereken vadesi geçmiş olanlar üstte
</CardDescription>
</div>
<Link
href="/invoices"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
Tümü <ExternalLink className="size-3" />
</Link>
</CardHeader>
<CardContent>
{data.outstandingInvoices.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Bekleyen fatura yok.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Numara</TableHead>
<TableHead>Müşteri</TableHead>
<TableHead>Vade</TableHead>
<TableHead className="text-right">Tutar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.outstandingInvoices.map((inv) => (
<TableRow key={inv.id}>
<TableCell>
<Link
href={`/invoices/${inv.id}`}
className="hover:text-primary inline-flex items-center gap-1 font-mono text-sm"
>
{inv.number}
</Link>
</TableCell>
<TableCell>{inv.customerName}</TableCell>
<TableCell
className={cn(
"text-sm",
inv.overdue
? "text-destructive flex items-center gap-1 font-medium"
: "text-muted-foreground",
)}
>
{inv.overdue && <AlertCircle className="size-3" />}
{formatDate(inv.dueDate)}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatTRY(inv.total)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
function KpiCard({
label,
value,
tone,
icon: Icon,
subtitle,
}: {
label: string;
value: string;
tone: "positive" | "negative" | "neutral";
icon: typeof Wallet;
subtitle?: string;
}) {
const cls = {
positive: "text-emerald-600 dark:text-emerald-400",
negative: "text-red-600 dark:text-red-400",
neutral: "text-muted-foreground",
}[tone];
return (
<Card>
<CardContent className="flex items-start justify-between p-5">
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wide">{label}</p>
<p className={cn("mt-2 text-2xl font-semibold tabular-nums", cls)}>{value}</p>
{subtitle && <p className="text-muted-foreground mt-1 text-xs">{subtitle}</p>}
</div>
<Icon className={cn("size-5", cls)} />
</CardContent>
</Card>
);
}
function CompositionRow({
icon: Icon,
label,
sign,
amount,
href,
}: {
icon: typeof Building2;
label: string;
sign: "+" | "";
amount: number;
href?: string;
}) {
const positive = sign === "+";
const content = (
<div className="flex items-center justify-between border-b py-2 last:border-b-0">
<div className="flex items-center gap-2">
<Icon className="text-muted-foreground size-4" />
<span className="text-sm">{label}</span>
</div>
<span
className={cn(
"font-medium tabular-nums",
positive ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400",
)}
>
{sign} {formatTRY(amount)}
</span>
</div>
);
if (href) {
return (
<Link href={href} className="hover:bg-muted/30 block rounded -mx-2 px-2 transition-colors">
{content}
</Link>
);
}
return content;
}
function ExpenseRow({
label,
amount,
total,
color,
}: {
label: string;
amount: number;
total: number;
color: string;
}) {
const pct = total > 0 ? (amount / total) * 100 : 0;
return (
<div className="space-y-1.5 py-1.5">
<div className="flex items-center justify-between text-sm">
<span>{label}</span>
<span className="tabular-nums">
{formatTRY(amount)}{" "}
<span className="text-muted-foreground text-xs">({pct.toFixed(1)}%)</span>
</span>
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div className={cn("h-full", color)} style={{ width: `${pct}%` }} />
</div>
</div>
);
}
// Pull TrendChart in via dynamic import only on client. Recharts is heavy.
import dynamic from "next/dynamic";
const TrendChartLazy = dynamic(
() => import("./trend-chart").then((m) => ({ default: m.TrendChart })),
{ ssr: false },
);
@@ -0,0 +1,89 @@
"use client";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { formatTRY } from "@/lib/format";
type Point = { month: string; income: number; expense: number; net: number };
export function TrendChart({ data }: { data: Point[] }) {
return (
<Card className="@container">
<CardHeader>
<CardTitle>12 aylık trend</CardTitle>
<CardDescription>Gelir, gider ve net kâr</CardDescription>
</CardHeader>
<CardContent className="h-[280px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<defs>
<linearGradient id="rIncome" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.4} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
<linearGradient id="rExpense" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
fontSize={11}
stroke="hsl(var(--muted-foreground))"
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={11}
stroke="hsl(var(--muted-foreground))"
tickFormatter={(v) => (v >= 1000 ? `${(v / 1000).toFixed(0)}k` : String(v))}
/>
<Tooltip
contentStyle={{
background: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: 8,
fontSize: 12,
}}
formatter={(value: unknown, name) => [
formatTRY(Number(value) || 0),
name === "income" ? "Gelir" : name === "expense" ? "Gider" : "Net",
]}
/>
<Legend
wrapperStyle={{ fontSize: 11 }}
formatter={(v) => (v === "income" ? "Gelir" : v === "expense" ? "Gider" : "Net")}
/>
<Area
type="monotone"
dataKey="income"
stroke="#10b981"
strokeWidth={2}
fill="url(#rIncome)"
/>
<Area
type="monotone"
dataKey="expense"
stroke="#ef4444"
strokeWidth={2}
fill="url(#rExpense)"
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import {
getFinancialReport,
type ReportPeriod,
} from "@/lib/appwrite/finance-report-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { ReportClient } from "./components/report-client";
export const metadata: Metadata = {
title: "İşletmem — Finansal rapor",
};
const ALLOWED: ReportPeriod[] = ["month", "quarter", "year", "all"];
export default async function ReportsPage({
searchParams,
}: {
searchParams: Promise<{ period?: string }>;
}) {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const sp = await searchParams;
const period: ReportPeriod = (ALLOWED as string[]).includes(sp.period ?? "")
? (sp.period as ReportPeriod)
: "month";
const data = await getFinancialReport(ctx.tenantId, period);
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
<h1 className="text-2xl font-bold tracking-tight">Finansal rapor</h1>
<p className="text-muted-foreground text-sm">
İşletmenizin nakit pozisyonu, gelir/gider performansı ve borç yükünün tek bakışta özeti.
</p>
</div>
<ReportClient data={data} />
</div>
);
}
@@ -0,0 +1,95 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Pencil, Printer, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { deleteInvoiceAction } from "@/lib/appwrite/invoice-actions";
import { InvoiceFormSheet } from "../../components/invoice-form-sheet";
import type { Customer, InvoiceRow } from "../../components/types";
type Props = { invoice: InvoiceRow; customers: Customer[] };
export function InvoiceHeaderActions({ invoice, customers }: Props) {
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [busy, startTransition] = useTransition();
const handleDelete = () => {
startTransition(async () => {
const fd = new FormData();
fd.set("id", invoice.id);
const result = await deleteInvoiceAction(fd);
if (result.ok) {
toast.success("Fatura silindi.");
router.push("/invoices");
} else {
toast.error(result.error ?? "Silme başarısız.");
setDeleting(false);
}
});
};
return (
<>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => window.print()}>
<Printer className="size-3.5" />
Yazdır
</Button>
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="size-3.5" />
Düzenle
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setDeleting(true)}
>
<Trash2 className="size-3.5" />
Sil
</Button>
</div>
<InvoiceFormSheet
open={editOpen}
onOpenChange={setEditOpen}
invoice={invoice}
customers={customers}
/>
<Dialog open={deleting} onOpenChange={setDeleting}>
<DialogContent>
<DialogHeader>
<DialogTitle>Faturayı sil</DialogTitle>
<DialogDescription>
<strong>{invoice.number}</strong> ve tüm kalemleri silinecek. Bu işlem geri alınamaz.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleting(false)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,303 @@
"use client";
import { useActionState, useEffect, useState, useTransition } from "react";
import { Loader2, Plus, Save, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
addInvoiceItemAction,
deleteInvoiceItemAction,
updateInvoiceItemAction,
} from "@/lib/appwrite/invoice-actions";
import { initialInvoiceState } from "@/lib/appwrite/invoice-types";
import { formatTRY } from "@/lib/format";
export type InvoiceItemRow = {
id: string;
description: string;
quantity: number;
unitPrice: number;
vatRate: number;
lineTotal: number;
};
type Props = { invoiceId: string; items: InvoiceItemRow[] };
export function InvoiceItemsEditor({ invoiceId, items }: Props) {
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<InvoiceItemRow | null>(null);
const [deleting, setDeleting] = useState<InvoiceItemRow | null>(null);
const [busy, startTransition] = useTransition();
const handleDelete = () => {
if (!deleting) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deleting.id);
const result = await deleteInvoiceItemAction(fd);
if (result.ok) {
toast.success("Kalem silindi.");
setDeleting(null);
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
return (
<>
<Card>
<CardContent className="p-0">
<div className="flex items-center justify-between border-b p-4">
<h2 className="text-sm font-semibold">Kalemler ({items.length})</h2>
<Button
size="sm"
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
>
<Plus className="size-3.5" />
Kalem ekle
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Açıklama</TableHead>
<TableHead className="w-[100px] text-right">Miktar</TableHead>
<TableHead className="w-[140px] text-right">Birim fiyat</TableHead>
<TableHead className="w-[80px] text-right">KDV %</TableHead>
<TableHead className="w-[140px] text-right">Toplam</TableHead>
<TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length ? (
items.map((it) => (
<TableRow
key={it.id}
className="cursor-pointer"
onClick={() => {
setEditing(it);
setFormOpen(true);
}}
>
<TableCell className="font-medium">{it.description}</TableCell>
<TableCell className="text-right tabular-nums">{it.quantity}</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(it.unitPrice)}
</TableCell>
<TableCell className="text-right tabular-nums">{it.vatRate}%</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatTRY(it.lineTotal)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={(e) => {
e.stopPropagation();
setDeleting(it);
}}
>
<Trash2 className="size-3.5" />
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="h-20 text-center">
<p className="text-muted-foreground text-sm">
Henüz kalem eklenmemiş. Yukarıdan ekleyin.
</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<ItemFormSheet
open={formOpen}
onOpenChange={(v) => {
setFormOpen(v);
if (!v) setEditing(null);
}}
invoiceId={invoiceId}
item={editing}
/>
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Kalemi sil</DialogTitle>
<DialogDescription>
<strong>{deleting?.description}</strong> kalemini silmek istediğinize emin misiniz?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleting(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function ItemFormSheet({
open,
onOpenChange,
invoiceId,
item,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
invoiceId: string;
item?: InvoiceItemRow | null;
}) {
const isEdit = Boolean(item);
const action = isEdit ? updateInvoiceItemAction : addInvoiceItemAction;
const [state, formAction, isPending] = useActionState(action, initialInvoiceState);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Kalem güncellendi." : "Kalem eklendi.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Kalemi düzenle" : "Yeni kalem"}</SheetTitle>
</SheetHeader>
<form action={formAction} className="flex flex-1 flex-col">
{isEdit && item && <input type="hidden" name="id" value={item.id} />}
{!isEdit && <input type="hidden" name="invoiceId" value={invoiceId} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div className="grid gap-2">
<Label htmlFor="description">Açıklama *</Label>
<Input
id="description"
name="description"
defaultValue={item?.description ?? ""}
placeholder="Hizmet / ürün açıklaması"
required
/>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="quantity">Miktar *</Label>
<Input
id="quantity"
name="quantity"
type="number"
step="0.01"
min="0.01"
defaultValue={item?.quantity ?? "1"}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="unitPrice">Birim () *</Label>
<Input
id="unitPrice"
name="unitPrice"
type="number"
step="0.01"
min="0"
defaultValue={item?.unitPrice ?? ""}
placeholder="0.00"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="vatRate">KDV %</Label>
<Input
id="vatRate"
name="vatRate"
type="number"
step="0.1"
min="0"
max="100"
defaultValue={item?.vatRate ?? "20"}
/>
</div>
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Ekle"}
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
+134
View File
@@ -0,0 +1,134 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { listCustomers } from "@/lib/appwrite/customer-queries";
import { getInvoice, listInvoiceItems } from "@/lib/appwrite/invoice-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { formatDate, formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
import { STATUS_COLOR, STATUS_LABEL } from "../components/types";
import { InvoiceItemsEditor } from "./components/items-editor";
import { InvoiceHeaderActions } from "./components/header-actions";
export const metadata: Metadata = {
title: "İşletmem — Fatura",
};
export default async function InvoiceDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const invoice = await getInvoice(ctx.tenantId, id);
if (!invoice) notFound();
const [items, customers] = await Promise.all([
listInvoiceItems(ctx.tenantId, id),
listCustomers(ctx.tenantId),
]);
const customerName = customers.find((c) => c.$id === invoice.customerId)?.name ?? "—";
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex items-center gap-2">
<Button asChild variant="ghost" size="sm">
<Link href="/invoices">
<ArrowLeft className="size-3.5" />
Faturalar
</Link>
</Button>
</div>
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div>
<p className="text-muted-foreground text-sm">{customerName}</p>
<h1 className="font-mono text-2xl font-bold tracking-tight">{invoice.number}</h1>
<div className="mt-2 flex flex-wrap items-center gap-3 text-sm">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs",
STATUS_COLOR[invoice.status ?? "draft"],
)}
>
{STATUS_LABEL[invoice.status ?? "draft"]}
</span>
<span className="text-muted-foreground">
Düzenleme: {formatDate(invoice.issueDate)}
</span>
<span className="text-muted-foreground">Vade: {formatDate(invoice.dueDate)}</span>
</div>
</div>
<InvoiceHeaderActions
invoice={{
id: invoice.$id,
number: invoice.number,
customerId: invoice.customerId,
customerName,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
status: invoice.status ?? "draft",
subtotal: invoice.subtotal ?? 0,
vatTotal: invoice.vatTotal ?? 0,
total: invoice.total ?? 0,
notes: invoice.notes ?? "",
}}
customers={customers.map((c) => ({ id: c.$id, name: c.name }))}
/>
</div>
<InvoiceItemsEditor
invoiceId={id}
items={items.map((it) => ({
id: it.$id,
description: it.description,
quantity: it.quantity,
unitPrice: it.unitPrice,
vatRate: it.vatRate ?? 0,
lineTotal: it.lineTotal,
}))}
/>
<Card>
<CardContent className="space-y-2 p-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Ara toplam</span>
<span className="tabular-nums">{formatTRY(invoice.subtotal ?? 0)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">KDV</span>
<span className="tabular-nums">{formatTRY(invoice.vatTotal ?? 0)}</span>
</div>
<div className="border-t pt-2"></div>
<div className="flex justify-between text-base font-semibold">
<span>Genel toplam</span>
<span className="tabular-nums">{formatTRY(invoice.total ?? 0)}</span>
</div>
</CardContent>
</Card>
{invoice.notes && (
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase">Notlar</p>
<p className="mt-1 whitespace-pre-line text-sm">{invoice.notes}</p>
</CardContent>
</Card>
)}
</div>
);
}
@@ -0,0 +1,194 @@
"use client";
import { useActionState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import {
createInvoiceAction,
updateInvoiceAction,
} from "@/lib/appwrite/invoice-actions";
import { initialInvoiceState } from "@/lib/appwrite/invoice-types";
import type { Customer, InvoiceRow } from "./types";
type Props = {
open: boolean;
onOpenChange: (v: boolean) => void;
invoice?: InvoiceRow | null;
customers: Customer[];
};
function isoToDate(iso: string): string {
if (!iso) return "";
return iso.slice(0, 10);
}
export function InvoiceFormSheet({ open, onOpenChange, invoice, customers }: Props) {
const isEdit = Boolean(invoice);
const action = isEdit ? updateInvoiceAction : createInvoiceAction;
const [state, formAction, isPending] = useActionState(action, initialInvoiceState);
const router = useRouter();
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Fatura güncellendi." : "Fatura oluşturuldu.");
onOpenChange(false);
if (!isEdit && state.invoiceId) {
router.push(`/invoices/${state.invoiceId}`);
}
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
const today = new Date().toISOString().slice(0, 10);
const inThirty = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Faturayı düzenle" : "Yeni fatura"}</SheetTitle>
<SheetDescription>
{isEdit
? "Fatura bilgilerini güncelleyin. Kalem eklemek için fatura detayına gidin."
: "Faturayı oluşturun, ardından detay sayfasında kalemleri ekleyin. Numara otomatik üretilir."}
</SheetDescription>
</SheetHeader>
<form action={formAction} className="flex flex-1 flex-col">
{isEdit && invoice && <input type="hidden" name="id" value={invoice.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div className="grid gap-2">
<Label htmlFor="customerId">Müşteri *</Label>
<Select
name="customerId"
defaultValue={invoice?.customerId ?? ""}
disabled={customers.length === 0}
>
<SelectTrigger id="customerId">
<SelectValue placeholder="Müşteri seçin" />
</SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{state.fieldErrors?.customerId && (
<p className="text-destructive text-xs">{state.fieldErrors.customerId}</p>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="issueDate">Düzenleme tarihi *</Label>
<Input
id="issueDate"
name="issueDate"
type="date"
defaultValue={isoToDate(invoice?.issueDate ?? today)}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dueDate">Vade tarihi *</Label>
<Input
id="dueDate"
name="dueDate"
type="date"
defaultValue={isoToDate(invoice?.dueDate ?? inThirty)}
required
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="status">Durum</Label>
<Select name="status" defaultValue={invoice?.status ?? "draft"}>
<SelectTrigger id="status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Taslak</SelectItem>
<SelectItem value="sent">Gönderildi</SelectItem>
<SelectItem value="paid">Ödendi</SelectItem>
<SelectItem value="overdue">Gecikmiş</SelectItem>
<SelectItem value="cancelled">İptal</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
&ldquo;Ödendi&rdquo; seçildiğinde finans modülüne otomatik gelir kaydı düşer.
Durum geri alınırsa kayıt silinir.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notlar</Label>
<Textarea
id="notes"
name="notes"
rows={4}
defaultValue={invoice?.notes ?? ""}
placeholder="Faturada görünecek not, ödeme talimatları, vb."
/>
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Vazgeç
</Button>
<Button type="submit" disabled={isPending || customers.length === 0}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Oluştur"}
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,372 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import Link from "next/link";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import {
ArrowUpRight,
ChevronLeft,
ChevronRight,
ExternalLink,
Loader2,
MoreHorizontal,
Plus,
Receipt,
Search,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { deleteInvoiceAction } from "@/lib/appwrite/invoice-actions";
import { formatDate, formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
import { InvoiceFormSheet } from "./invoice-form-sheet";
import { type Customer, type InvoiceRow, STATUS_COLOR, STATUS_LABEL } from "./types";
type Props = { invoices: InvoiceRow[]; customers: Customer[] };
export function InvoicesClient({ invoices, customers }: Props) {
const [search, setSearch] = useState("");
const [sorting, setSorting] = useState<SortingState>([]);
const [formOpen, setFormOpen] = useState(false);
const [deleting, setDeleting] = useState<InvoiceRow | null>(null);
const [busy, startTransition] = useTransition();
const stats = useMemo(() => {
let total = 0;
let outstanding = 0;
let paid = 0;
let overdue = 0;
for (const i of invoices) {
total += i.total;
if (i.status === "paid") paid += i.total;
else if (i.status === "overdue") {
outstanding += i.total;
overdue += i.total;
} else if (i.status === "sent" || i.status === "draft") outstanding += i.total;
}
return { total, outstanding, paid, overdue };
}, [invoices]);
const columns = useMemo<ColumnDef<InvoiceRow>[]>(
() => [
{
accessorKey: "number",
header: "Numara",
cell: ({ row }) => (
<Link
href={`/invoices/${row.original.id}`}
className="hover:text-primary inline-flex items-center gap-1 font-mono text-sm font-medium"
>
{row.original.number}
<ExternalLink className="size-3 opacity-0 transition-opacity group-hover:opacity-100" />
</Link>
),
},
{
accessorKey: "customerName",
header: "Müşteri",
cell: ({ row }) => row.original.customerName,
},
{
accessorKey: "issueDate",
header: "Tarih",
cell: ({ row }) => (
<span className="text-muted-foreground">{formatDate(row.original.issueDate)}</span>
),
},
{
accessorKey: "dueDate",
header: "Vade",
cell: ({ row }) => {
const overdue =
row.original.status !== "paid" &&
row.original.status !== "cancelled" &&
new Date(row.original.dueDate) < new Date();
return (
<span
className={cn(
"text-muted-foreground",
overdue && "text-destructive font-medium",
)}
>
{formatDate(row.original.dueDate)}
</span>
);
},
},
{
accessorKey: "status",
header: "Durum",
cell: ({ row }) => (
<Badge variant="outline" className={cn("border-0", STATUS_COLOR[row.original.status])}>
{STATUS_LABEL[row.original.status]}
</Badge>
),
},
{
accessorKey: "total",
header: "Toplam",
cell: ({ row }) => (
<span className="font-medium tabular-nums">{formatTRY(row.original.total)}</span>
),
},
{
id: "actions",
cell: ({ row }) => (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/invoices/${row.original.id}`}>
<ArrowUpRight className="size-3.5" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleting(row.original)}
>
<Trash2 className="size-3.5" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
],
[],
);
const table = useReactTable({
data: invoices,
columns,
state: { globalFilter: search, sorting },
onGlobalFilterChange: setSearch,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 25 } },
globalFilterFn: (row, _id, fv) => {
const v = String(fv).toLowerCase();
return [row.original.number, row.original.customerName, row.original.notes]
.join(" ")
.toLowerCase()
.includes(v);
},
});
const handleDelete = () => {
if (!deleting) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deleting.id);
const result = await deleteInvoiceAction(fd);
if (result.ok) {
toast.success("Fatura silindi.");
setDeleting(null);
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
return (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Toplam</p>
<p className="mt-1 text-xl font-semibold">{formatTRY(stats.total)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Tahsil edildi</p>
<p className="mt-1 text-xl font-semibold text-emerald-600 dark:text-emerald-400">
{formatTRY(stats.paid)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Bekleyen</p>
<p className="mt-1 text-xl font-semibold text-blue-600 dark:text-blue-400">
{formatTRY(stats.outstanding)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Gecikmiş</p>
<p className="mt-1 text-xl font-semibold text-red-600 dark:text-red-400">
{formatTRY(stats.overdue)}
</p>
</CardContent>
</Card>
</div>
<Card>
<CardContent className="p-0">
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
<div className="relative md:max-w-xs md:flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Numara, müşteri, not..."
className="pl-9"
/>
</div>
<Button onClick={() => setFormOpen(true)} disabled={customers.length === 0}>
<Plus className="size-4" />
Yeni fatura
</Button>
</div>
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<TableHead key={h.id}>
{h.isPlaceholder
? null
: flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((r) => (
<TableRow key={r.id} className="group">
{r.getVisibleCells().map((c) => (
<TableCell key={c.id}>
{flexRender(c.column.columnDef.cell, c.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-32 text-center">
<div className="text-muted-foreground flex flex-col items-center gap-2">
<Receipt className="size-6" />
<p className="text-sm">
{customers.length === 0
? "Önce müşteri ekleyin, sonra fatura kesebilirsiniz."
: "Henüz fatura yok."}
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="flex items-center justify-between border-t px-4 py-3">
<p className="text-muted-foreground text-sm">
Toplam {table.getFilteredRowModel().rows.length} fatura
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-muted-foreground text-sm">
Sayfa {table.getState().pagination.pageIndex + 1} /{" "}
{Math.max(table.getPageCount(), 1)}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
<InvoiceFormSheet
open={formOpen}
onOpenChange={setFormOpen}
customers={customers}
/>
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Faturayı sil</DialogTitle>
<DialogDescription>
<strong>{deleting?.number}</strong> ve tüm kalemleri silinecek. Bu işlem geri alınamaz.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleting(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,33 @@
export type InvoiceStatus = "draft" | "sent" | "paid" | "overdue" | "cancelled";
export type InvoiceRow = {
id: string;
number: string;
customerId: string;
customerName: string;
issueDate: string;
dueDate: string;
status: InvoiceStatus;
subtotal: number;
vatTotal: number;
total: number;
notes: string;
};
export type Customer = { id: string; name: string };
export const STATUS_LABEL: Record<InvoiceStatus, string> = {
draft: "Taslak",
sent: "Gönderildi",
paid: "Ödendi",
overdue: "Gecikmiş",
cancelled: "İptal",
};
export const STATUS_COLOR: Record<InvoiceStatus, string> = {
draft: "bg-slate-500/15 text-slate-700 dark:text-slate-300 border-slate-500/30",
sent: "bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30",
paid: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
overdue: "bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30",
cancelled: "bg-muted text-muted-foreground border-muted-foreground/30",
};
+56
View File
@@ -0,0 +1,56 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { listCustomers } from "@/lib/appwrite/customer-queries";
import { listInvoices } from "@/lib/appwrite/invoice-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { InvoicesClient } from "./components/invoices-client";
export const metadata: Metadata = {
title: "İşletmem — Faturalar",
};
export default async function InvoicesPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const [invoices, customers] = await Promise.all([
listInvoices(ctx.tenantId),
listCustomers(ctx.tenantId),
]);
const customerMap = new Map(customers.map((c) => [c.$id, c.name]));
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
<h1 className="text-2xl font-bold tracking-tight">Faturalar</h1>
<p className="text-muted-foreground text-sm">
Müşterilerinize fatura kesin, kalemleri yönetin, durumu takip edin.
</p>
</div>
<InvoicesClient
invoices={invoices.map((i) => ({
id: i.$id,
number: i.number,
customerId: i.customerId,
customerName: customerMap.get(i.customerId) ?? "—",
issueDate: i.issueDate,
dueDate: i.dueDate,
status: i.status ?? "draft",
subtotal: i.subtotal ?? 0,
vatTotal: i.vatTotal ?? 0,
total: i.total ?? 0,
notes: i.notes ?? "",
}))}
customers={customers.map((c) => ({ id: c.$id, name: c.name }))}
/>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import { getActiveContext } from "@/lib/appwrite/active-context";
import { getLogoUrl } from "@/lib/appwrite/storage";
import { DashboardShell } from "./dashboard-shell";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const ctx = await getActiveContext();
if (!ctx) redirect("/onboarding");
const company = {
id: ctx.tenantId,
name: ctx.settings?.companyName ?? "Çalışma alanı",
logoUrl: getLogoUrl(ctx.settings?.logo) ?? null,
};
const user = {
id: ctx.user.id,
name: ctx.user.name || ctx.user.email,
email: ctx.user.email,
};
return (
<DashboardShell user={user} company={company}>
{children}
</DashboardShell>
);
}
@@ -0,0 +1,24 @@
"use server";
import { listLeadActivities } from "@/lib/appwrite/lead-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import type { ActivityRow } from "./types";
export async function listLeadActivitiesForClient(leadId: string): Promise<ActivityRow[]> {
try {
const ctx = await requireTenant();
const rows = await listLeadActivities(ctx.tenantId, leadId);
return rows.map((a) => ({
id: a.$id,
leadId: a.leadId,
type: a.type,
content: a.content,
calendarEventId: a.calendarEventId ?? null,
occurredAt: a.occurredAt ?? null,
createdAt: a.$createdAt,
createdByName: "",
}));
} catch {
return [];
}
}

Some files were not shown because too many files have changed in this diff Show More