init: lab project bootstrapped from isletmem-kovakcrm

- CRM domain modules removed (customers, services, software, calendar, tasks, invoices, leads, finance, etc.)
- DLS branding: package name=lab, logo wordmark, sidebar nav, header CTA
- Tenant layer extended with kind dimension (lab|clinic) + requireTenantKind helper
- Schema rewritten for DLS domain: jobs, job_files, job_status_history, prosthetics, connections, finance_entries, notifications
- Onboarding form: clinic/lab account-type selection + auto-generated memberNumber
- Placeholder routes for jobs/{inbound,outbound,new}, products, finance, connections
- PDF spec + spec.md under belgeler/
- db: lab database + 13 collections + indexes + storage bucket (job-files) provisioned via Appwrite MCP

Ref: belgeler/dls-ui-tasarim.pdf
This commit is contained in:
kovakmedya
2026-05-21 18:28:38 +03:00
commit cb150f7a24
215 changed files with 54262 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
export default async function ConnectionsPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Bağlantı Kur</h1>
<p className="text-muted-foreground text-sm">
Klinik ve laboratuvar arasında bağlantı taleplerini yönetin.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Bağlantı kodunuz</CardTitle>
<CardDescription>Karşı taraf bu kodu girerek size bağlantı talebi gönderir.</CardDescription>
</CardHeader>
<CardContent>
<div className="bg-muted/40 rounded-md border px-4 py-3 font-mono text-lg tracking-widest">
{ctx.settings?.memberNumber ?? "—"}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Yapım aşamasında</CardTitle>
<CardDescription>Bağlantı talepleri ve bağlı taraflar listesi sonraki sürümde eklenecek.</CardDescription>
</CardHeader>
<CardContent />
</Card>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
"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 { PrefsInitializer } from "@/components/theme-customizer/prefs-initializer";
import { useSidebarConfig } from "@/hooks/use-sidebar-config";
import type { UserPrefs as ThemePrefs } from "@/lib/appwrite/user-prefs-actions";
export type ShellUser = {
id: string;
name: string;
email: string;
};
export type TenantKind = "lab" | "clinic";
export type ShellCompany = {
id: string;
name: string;
logoUrl?: string | null;
kind: TenantKind;
};
export function DashboardShell({
user,
company,
children,
initialPrefs,
}: {
user: ShellUser;
company: ShellCompany;
children: React.ReactNode;
initialPrefs: ThemePrefs;
}) {
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" : ""}
>
<PrefsInitializer prefs={initialPrefs} />
{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}
initialPrefs={initialPrefs}
/>
</SidebarProvider>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { getActiveContext } from "@/lib/appwrite/active-context";
export default async function DashboardPage() {
const ctx = await getActiveContext();
if (!ctx) redirect("/onboarding");
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 gap-1">
<p className="text-muted-foreground text-sm">{companyName}</p>
<h1 className="text-2xl font-bold tracking-tight">
{firstName ? `Hoş geldiniz, ${firstName}` : "Anasayfa"}
</h1>
<p className="text-muted-foreground text-sm">
Açık işleri, bildirimleri ve istatistikleri buradan takip edin.
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Açık işler</CardTitle>
<CardDescription>Gelen ve giden özetleri burada listelenecek.</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
Modül yapım aşamasında.
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>İşlem bekleyen</CardTitle>
<CardDescription>Onay/işlem bekleyen kalemler.</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
Modül yapım aşamasında.
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Bildirimler</CardTitle>
<CardDescription>Bağlantılarınızdan gelen son bildirimler.</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
Modül yapım aşamasında.
</CardContent>
</Card>
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default function FinancePage() {
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Finans</h1>
<p className="text-muted-foreground text-sm">
Gelen ödemeler, ödenen hesaplar ve bekleyen tahsilatlar.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Yapım aşamasında</CardTitle>
<CardDescription>Finans hareketleri, durum takibi ve raporlar sonraki sürümde.</CardDescription>
</CardHeader>
<CardContent />
</Card>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default function InboundJobsPage() {
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Gelen İşler</h1>
<p className="text-muted-foreground text-sm">
Bağlı kliniklerden gelen protez işleri burada listelenecek.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Yapım aşamasında</CardTitle>
<CardDescription>Gelen listesi, filtreleme ve detay görünümü sonraki sürümde eklenecek.</CardDescription>
</CardHeader>
<CardContent />
</Card>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard";
export default async function NewJobPage() {
let ctx;
try {
ctx = await requireTenant();
requireTenantKind(ctx, ["clinic"]);
} catch {
redirect("/dashboard");
}
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Yeni İş Yayınla</h1>
<p className="text-muted-foreground text-sm">
Bağlı laboratuvarınıza yeni bir protez işi gönderin.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Yapım aşamasında</CardTitle>
<CardDescription>
Form (lab seçimi, hasta kodu, protez türü, renk, dosya yükleme) sonraki sürümde eklenecek.
</CardDescription>
</CardHeader>
<CardContent />
</Card>
</div>
);
}
@@ -0,0 +1,21 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default function OutboundJobsPage() {
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Giden İşler</h1>
<p className="text-muted-foreground text-sm">
Karşı tarafa gönderilen protez işleri burada listelenecek.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Yapım aşamasında</CardTitle>
<CardDescription>Giden listesi sonraki sürümde eklenecek.</CardDescription>
</CardHeader>
<CardContent />
</Card>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { redirect } from "next/navigation";
import { getActiveContext } from "@/lib/appwrite/active-context";
import { getLogoUrl } from "@/lib/appwrite/storage";
import { getUserPrefs } from "@/lib/appwrite/user-prefs-actions";
import type { UserPrefs as ThemePrefs } from "@/lib/appwrite/user-prefs-actions";
import { DashboardShell } from "./dashboard-shell";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const ctx = await getActiveContext();
if (!ctx) redirect("/onboarding");
const themePrefs: ThemePrefs = await getUserPrefs();
const company = {
id: ctx.tenantId,
name: ctx.settings?.companyName ?? "Çalışma alanı",
logoUrl: getLogoUrl(ctx.settings?.logo) ?? null,
kind: (ctx.settings?.kind ?? "lab") as "lab" | "clinic",
};
const user = {
id: ctx.user.id,
name: ctx.user.name || ctx.user.email,
email: ctx.user.email,
};
return (
<DashboardShell user={user} company={company} initialPrefs={themePrefs}>
{children}
</DashboardShell>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard";
export default async function ProductsPage() {
let ctx;
try {
ctx = await requireTenant();
requireTenantKind(ctx, ["lab"]);
} catch {
redirect("/dashboard");
}
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Ürünler</h1>
<p className="text-muted-foreground text-sm">
Sunduğunuz protez türleri ve fiyatlandırma katalogu.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Yapım aşamasında</CardTitle>
<CardDescription>Ürün ekleme/düzenleme sonraki sürümde eklenecek.</CardDescription>
</CardHeader>
<CardContent />
</Card>
</div>
);
}
@@ -0,0 +1,83 @@
"use client";
import { useActionState, useEffect, useRef } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
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 { updateEmailAction } from "@/lib/appwrite/profile-actions";
import { initialProfileState } from "@/lib/appwrite/profile-types";
export function EmailForm({ currentEmail }: { currentEmail: string }) {
const [state, formAction, isPending] = useActionState(updateEmailAction, initialProfileState);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.ok) {
toast.success("Email güncellendi.");
// Clear password field after success
formRef.current?.reset();
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
return (
<Card>
<CardHeader>
<CardTitle>Email adresi</CardTitle>
<CardDescription>
Email değiştirmek için mevcut şifrenizi de girin. Yeni email ile giriş yapmaya devam edersiniz.
</CardDescription>
</CardHeader>
<CardContent>
<form ref={formRef} action={formAction} className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="email">Yeni email</Label>
<Input
id="email"
name="email"
type="email"
defaultValue={currentEmail}
required
/>
{state.fieldErrors?.email && (
<p className="text-destructive text-xs">{state.fieldErrors.email}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="email-password">Şifre (doğrulama)</Label>
<Input
id="email-password"
name="password"
type="password"
autoComplete="current-password"
required
/>
{state.fieldErrors?.password && (
<p className="text-destructive text-xs">{state.fieldErrors.password}</p>
)}
</div>
<div className="md:col-span-2 flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Güncelleniyor...
</>
) : (
<>
<Save className="size-4" />
Email'i güncelle
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,56 @@
"use client";
import { useActionState, useEffect } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
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 { updateNameAction } from "@/lib/appwrite/profile-actions";
import { initialProfileState } from "@/lib/appwrite/profile-types";
export function NameForm({ currentName }: { currentName: string }) {
const [state, formAction, isPending] = useActionState(updateNameAction, initialProfileState);
useEffect(() => {
if (state.ok) toast.success("İsim güncellendi.");
else if (state.error) toast.error(state.error);
}, [state]);
return (
<Card>
<CardHeader>
<CardTitle>Görünür isim</CardTitle>
<CardDescription>
Header'da, davetlerde ve takım listesinde görünecek isim.
</CardDescription>
</CardHeader>
<CardContent>
<form action={formAction} className="grid gap-4 md:grid-cols-[1fr_auto] md:items-end">
<div className="grid gap-2">
<Label htmlFor="name">İsim</Label>
<Input id="name" name="name" defaultValue={currentName} required maxLength={128} />
{state.fieldErrors?.name && (
<p className="text-destructive text-xs">{state.fieldErrors.name}</p>
)}
</div>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
Kaydet
</>
)}
</Button>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,100 @@
"use client";
import { useActionState, useEffect, useRef } from "react";
import { KeyRound, Loader2 } from "lucide-react";
import { toast } from "sonner";
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 { updatePasswordAction } from "@/lib/appwrite/profile-actions";
import { initialProfileState } from "@/lib/appwrite/profile-types";
export function PasswordForm() {
const [state, formAction, isPending] = useActionState(
updatePasswordAction,
initialProfileState,
);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.ok) {
toast.success("Şifre değiştirildi.");
formRef.current?.reset();
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
return (
<Card>
<CardHeader>
<CardTitle>Şifre</CardTitle>
<CardDescription>
Şifrenizi değiştirmek için mevcut şifrenizi ve yeni şifreyi iki kez girin.
</CardDescription>
</CardHeader>
<CardContent>
<form ref={formRef} action={formAction} className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="oldPassword">Mevcut şifre</Label>
<Input
id="oldPassword"
name="oldPassword"
type="password"
autoComplete="current-password"
required
/>
{state.fieldErrors?.oldPassword && (
<p className="text-destructive text-xs">{state.fieldErrors.oldPassword}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="newPassword">Yeni şifre</Label>
<Input
id="newPassword"
name="newPassword"
type="password"
autoComplete="new-password"
minLength={8}
required
/>
{state.fieldErrors?.newPassword && (
<p className="text-destructive text-xs">{state.fieldErrors.newPassword}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="confirmPassword">Yeni şifre (tekrar)</Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
minLength={8}
required
/>
{state.fieldErrors?.confirmPassword && (
<p className="text-destructive text-xs">{state.fieldErrors.confirmPassword}</p>
)}
</div>
<div className="md:col-span-3 flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Güncelleniyor...
</>
) : (
<>
<KeyRound className="size-4" />
Şifreyi değiştir
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,62 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/appwrite/server";
import { formatDateTime } from "@/lib/format";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { NameForm } from "./components/name-form";
import { EmailForm } from "./components/email-form";
import { PasswordForm } from "./components/password-form";
export const metadata: Metadata = {
title: "DLS — Profil",
};
export default async function AccountSettingsPage() {
const user = await getCurrentUser();
if (!user) redirect("/sign-in");
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">Profil ayarları</p>
<h1 className="text-2xl font-bold tracking-tight">{user.name || user.email}</h1>
<p className="text-muted-foreground text-sm">
Hesap bilgilerinizi ve şifrenizi buradan yönetin.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Hesap bilgileri</CardTitle>
<CardDescription>Kayıt tarihi ve hesap durumu</CardDescription>
</CardHeader>
<CardContent>
<dl className="grid gap-4 text-sm md:grid-cols-2">
<div>
<dt className="text-muted-foreground text-xs uppercase">Hesap ID</dt>
<dd className="mt-1 font-mono text-xs">{user.$id}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs uppercase">Kayıt tarihi</dt>
<dd className="mt-1">{formatDateTime(user.registration)}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs uppercase">Email doğrulanmış</dt>
<dd className="mt-1">{user.emailVerification ? "Evet" : "Hayır"}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs uppercase">İki faktör (2FA)</dt>
<dd className="mt-1">{user.mfa ? "Açık" : "Kapalı"}</dd>
</div>
</dl>
</CardContent>
</Card>
<NameForm currentName={user.name || ""} />
<EmailForm currentEmail={user.email} />
<PasswordForm />
</div>
);
}
@@ -0,0 +1,229 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const appearanceFormSchema = z.object({
theme: z.enum(["light", "dark"]),
fontFamily: z.string().optional(),
fontSize: z.string().optional(),
sidebarWidth: z.string().optional(),
contentWidth: z.string().optional(),
})
type AppearanceFormValues = z.infer<typeof appearanceFormSchema>
export default function AppearanceSettings() {
const form = useForm<AppearanceFormValues>({
resolver: zodResolver(appearanceFormSchema),
defaultValues: {
theme: "dark",
fontFamily: "",
fontSize: "",
sidebarWidth: "",
contentWidth: "",
},
})
function onSubmit(data: AppearanceFormValues) {
console.log("Form submitted:", data)
// Here you would typically save the data
}
return (
<div className="space-y-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Appearance</h1>
<p className="text-muted-foreground">
Customize the appearance of the application.
</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Theme Section */}
<h3 className="text-lg font-medium mb-2">Theme</h3>
<FormField
control={form.control}
name="theme"
render={({ field }) => (
<FormItem className="space-y-3">
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4"
>
<FormItem>
<FormLabel className="[&:has([data-state=checked])>div]:border-primary cursor-pointer">
<FormControl>
<RadioGroupItem value="light" className="sr-only" />
</FormControl>
<div className="rounded-md border-2 border-muted p-4 hover:border-accent transition-colors">
<div className="space-y-2">
<div className="w-20 h-20 bg-white border rounded-md p-3">
<div className="space-y-2">
<div className="h-2 bg-gray-200 rounded w-3/4"></div>
<div className="h-2 bg-gray-200 rounded w-1/2"></div>
<div className="flex space-x-2">
<div className="h-2 w-2 bg-gray-300 rounded-full"></div>
<div className="h-2 bg-gray-200 rounded flex-1"></div>
</div>
<div className="flex space-x-2">
<div className="h-2 w-2 bg-gray-300 rounded-full"></div>
<div className="h-2 bg-gray-200 rounded flex-1"></div>
</div>
</div>
</div>
<span className="text-sm font-medium">Light</span>
</div>
</div>
</FormLabel>
</FormItem>
<FormItem>
<FormLabel className="[&:has([data-state=checked])>div]:border-primary cursor-pointer">
<FormControl>
<RadioGroupItem value="dark" className="sr-only" />
</FormControl>
<div className="rounded-md border-2 border-muted p-4 hover:border-accent transition-colors">
<div className="space-y-2">
<div className="w-20 h-20 bg-gray-900 border border-gray-700 rounded-md p-3">
<div className="space-y-2">
<div className="h-2 bg-gray-600 rounded w-3/4"></div>
<div className="h-2 bg-gray-600 rounded w-1/2"></div>
<div className="flex space-x-2">
<div className="h-2 w-2 bg-gray-500 rounded-full"></div>
<div className="h-2 bg-gray-600 rounded flex-1"></div>
</div>
<div className="flex space-x-2">
<div className="h-2 w-2 bg-gray-500 rounded-full"></div>
<div className="h-2 bg-gray-600 rounded flex-1"></div>
</div>
</div>
</div>
<span className="text-sm font-medium">Dark</span>
</div>
</div>
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fontFamily"
render={({ field }) => (
<FormItem>
<FormLabel>Font Family</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder="Select a font" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="inter">Inter</SelectItem>
<SelectItem value="roboto">Roboto</SelectItem>
<SelectItem value="system">System Default</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fontSize"
render={({ field }) => (
<FormItem>
<FormLabel>Font Size</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder="Select font size" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="small">Small</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="large">Large</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Layout Section */}
<FormField
control={form.control}
name="sidebarWidth"
render={({ field }) => (
<FormItem>
<FormLabel>Sidebar Width</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder="Select sidebar width" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="compact">Compact</SelectItem>
<SelectItem value="comfortable">Comfortable</SelectItem>
<SelectItem value="spacious">Spacious</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contentWidth"
render={({ field }) => (
<FormItem>
<FormLabel>Content Width</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder="Select content width" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="fixed">Fixed</SelectItem>
<SelectItem value="fluid">Fluid</SelectItem>
<SelectItem value="container">Container</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex space-x-2 mt-12">
<Button type="submit" className="cursor-pointer">
Save Preferences
</Button>
<Button variant="outline" type="button" className="cursor-pointer">Cancel</Button>
</div>
</form>
</Form>
</div>
)
}
@@ -0,0 +1,283 @@
"use client"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { Github, Slack, Twitter, Zap, Globe, Database, Apple, Chrome, Facebook, Instagram, Dribbble } from "lucide-react"
import { useState } from "react"
export default function ConnectionSettings() {
// Controlled state for switches
const [appleConnected, setAppleConnected] = useState(true)
const [googleConnected, setGoogleConnected] = useState(false)
const [githubConnected, setGithubConnected] = useState(true)
const [slackConnected, setSlackConnected] = useState(false)
const [zapierConnected, setZapierConnected] = useState(true)
const [webhooksConnected, setWebhooksConnected] = useState(false)
const [dbConnected, setDbConnected] = useState(true)
return (
<div className="space-y-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Connections</h1>
<p className="text-muted-foreground">
Connect your account with third-party services and integrations.
</p>
</div>
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Connected Accounts</CardTitle>
<CardDescription>
Display content from your connected accounts on your site
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Apple className="h-8 w-8" />
<div>
<div className="font-medium">Apple</div>
<div className="text-sm text-muted-foreground">Calendar and contacts</div>
</div>
</div>
<Switch
className="cursor-pointer"
checked={appleConnected}
onCheckedChange={setAppleConnected}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Chrome className="h-8 w-8" />
<div>
<div className="font-medium">Google</div>
<div className="text-sm text-muted-foreground">Calendar and contacts</div>
</div>
</div>
<Switch
className="cursor-pointer"
checked={googleConnected}
onCheckedChange={setGoogleConnected}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Github className="h-8 w-8" />
<div>
<div className="font-medium">Github</div>
<div className="text-sm text-muted-foreground">Manage your Git repositories</div>
</div>
</div>
<Switch
className="cursor-pointer"
checked={githubConnected}
onCheckedChange={setGithubConnected}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Slack className="h-8 w-8" />
<div>
<div className="font-medium">Slack</div>
<div className="text-sm text-muted-foreground">Communication</div>
</div>
</div>
<Switch
className="cursor-pointer"
checked={slackConnected}
onCheckedChange={setSlackConnected}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Social Accounts</CardTitle>
<CardDescription>
Display content from your connected accounts on your site
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Facebook className="h-8 w-8" />
<div>
<div className="font-medium">
Facebook
<Badge variant="outline" className="ml-2">Not Connected</Badge>
</div>
<div className="text-sm text-muted-foreground">Share updates on Facebook</div>
</div>
</div>
<Button variant="outline" size="icon" className="cursor-pointer">
<Globe className="h-4 w-4" />
</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Twitter className="h-8 w-8" />
<div>
<div className="font-medium">
Twitter
<Badge variant="secondary" className="ml-2">connected</Badge>
</div>
<div className="text-sm text-muted-foreground">Share updates on Twitter</div>
</div>
</div>
<Button variant="outline" size="icon" className="cursor-pointer text-destructive">
<Globe className="h-4 w-4" />
</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Instagram className="h-8 w-8" />
<div>
<div className="font-medium">
Instagram
<Badge variant="secondary" className="ml-2">connected</Badge>
</div>
<div className="text-sm text-muted-foreground">Stay connected at Instagram</div>
</div>
</div>
<Button variant="outline" size="icon" className="cursor-pointer text-destructive">
<Globe className="h-4 w-4" />
</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Dribbble className="h-8 w-8" />
<div>
<div className="font-medium">
Dribbble
<Badge variant="outline" className="ml-2">Not Connected</Badge>
</div>
<div className="text-sm text-muted-foreground">Stay connected at Dribbble</div>
</div>
</div>
<Button variant="outline" size="icon" className="cursor-pointer">
<Globe className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>API Integrations</CardTitle>
<CardDescription>
Configure API connections and webhooks.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Zap className="h-8 w-8" />
<div>
<div className="font-medium">Zapier</div>
<div className="text-sm text-muted-foreground">Automate workflows with Zapier</div>
</div>
</div>
<Switch
className="cursor-pointer"
checked={zapierConnected}
onCheckedChange={setZapierConnected}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Globe className="h-8 w-8" />
<div>
<div className="font-medium">Webhooks</div>
<div className="text-sm text-muted-foreground">Configure custom webhook endpoints</div>
</div>
</div>
<Switch
className="cursor-pointer"
checked={webhooksConnected}
onCheckedChange={setWebhooksConnected}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Database className="h-8 w-8" />
<div>
<div className="font-medium">Database Sync</div>
<div className="text-sm text-muted-foreground">Sync data with external databases</div>
</div>
</div>
<Switch
className="cursor-pointer"
checked={dbConnected}
onCheckedChange={setDbConnected}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>API Keys</CardTitle>
<CardDescription>
Manage your API keys and access tokens.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
<div className="font-medium">Production API Key</div>
<div className="text-sm text-muted-foreground font-mono">sk_live_4234</div>
</div>
<div className="flex space-x-2">
<Button variant="outline" size="sm" className="cursor-pointer">
Regenerate
</Button>
<Button variant="outline" size="sm" className="cursor-pointer">
Copy
</Button>
</div>
</div>
<Separator />
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
<div className="font-medium">Development API Key</div>
<div className="text-sm text-muted-foreground font-mono">sk_test_5678</div>
</div>
<div className="flex space-x-2">
<Button variant="outline" size="sm" className="cursor-pointer">
Regenerate
</Button>
<Button variant="outline" size="sm" className="cursor-pointer">
Copy
</Button>
</div>
</div>
<Separator />
<div className="pt-4">
<Button variant="outline" className="cursor-pointer">Add New API Key</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
)
}
@@ -0,0 +1,114 @@
"use client";
import { useActionState, useEffect, useRef, useState } from "react";
import { Check, Copy, Loader2, UserPlus } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { inviteMemberAction } from "@/lib/appwrite/team-actions";
import { initialInviteState } from "@/lib/appwrite/team-types";
export function InviteForm() {
const [state, formAction, isPending] = useActionState(inviteMemberAction, initialInviteState);
const [copied, setCopied] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.ok && formRef.current) {
formRef.current.reset();
}
}, [state.ok, state.shortUrl]);
const copy = async () => {
if (!state.shortUrl) return;
try {
await navigator.clipboard.writeText(state.shortUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
/* ignore */
}
};
return (
<Card>
<CardHeader>
<CardTitle>Üye davet et</CardTitle>
<CardDescription>
Email ve rol girin, oluşturulan kısa linki kopyalayıp davet edeceğiniz kişiye gönderin.
</CardDescription>
</CardHeader>
<CardContent>
<form ref={formRef} action={formAction} className="grid gap-4 md:grid-cols-[1fr_180px_auto]">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="ornek@firma.com"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="role">Rol</Label>
<Select name="role" defaultValue="member">
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Üye</SelectItem>
<SelectItem value="admin">Yönetici</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button type="submit" disabled={isPending} className="w-full md:w-auto">
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Gönderiliyor...
</>
) : (
<>
<UserPlus className="size-4" />
Davet et
</>
)}
</Button>
</div>
</form>
{state.error && (
<p className="text-destructive mt-3 text-sm" role="alert">
{state.error}
</p>
)}
{state.ok && state.shortUrl && (
<div className="bg-muted/50 mt-4 flex flex-col gap-2 rounded-md border p-3">
{state.message && (
<p className="text-muted-foreground text-xs">{state.message}</p>
)}
<div className="flex items-center gap-2">
<Input value={state.shortUrl} readOnly className="font-mono text-xs" />
<Button type="button" variant="outline" size="sm" onClick={copy}>
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
{copied ? "Kopyalandı" : "Kopyala"}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,286 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { DoorOpen, Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
leaveWorkspaceAction,
removeMemberAction,
updateMemberRoleAction,
} from "@/lib/appwrite/team-actions";
type Member = {
id: string;
userId: string;
name: string;
email: string;
role: string;
joined: string;
invited: string;
confirm: boolean;
};
const ROLE_LABEL: Record<string, string> = {
owner: "Sahip",
admin: "Yönetici",
member: "Üye",
};
export function MembersTable({
members,
currentUserId,
isOwner,
canManage,
}: {
members: Member[];
currentUserId: string;
isOwner: boolean;
canManage: boolean;
}) {
const router = useRouter();
const [busy, setBusy] = useState<string | null>(null);
const [, startTransition] = useTransition();
const [removing, setRemoving] = useState<Member | null>(null);
const [leaving, setLeaving] = useState(false);
const setRole = (membershipId: string, role: string) => {
setBusy(membershipId);
startTransition(async () => {
const result = await updateMemberRoleAction({ ok: false }, formDataFor({
membershipId,
role,
}));
if (result.ok) toast.success("Rol güncellendi.");
else toast.error(result.error ?? "Rol güncellenemedi.");
setBusy(null);
});
};
const handleRemove = () => {
if (!removing) return;
setBusy(removing.id);
startTransition(async () => {
const result = await removeMemberAction({ ok: false }, formDataFor({
membershipId: removing.id,
}));
if (result.ok) {
toast.success(`${removing.name} ekipten çıkarıldı.`);
setRemoving(null);
} else {
toast.error(result.error ?? "İşlem başarısız.");
}
setBusy(null);
});
};
const handleLeave = () => {
setBusy("leave");
startTransition(async () => {
const result = await leaveWorkspaceAction();
if (result.ok) {
toast.success("Çalışma alanından ayrıldınız.");
setLeaving(false);
router.push("/dashboard");
} else {
toast.error(result.error ?? "Ayrılma başarısız.");
}
setBusy(null);
});
};
return (
<>
<Card>
<CardHeader>
<CardTitle>Üyeler ({members.length})</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>İsim</TableHead>
<TableHead>Email</TableHead>
<TableHead>Rol</TableHead>
<TableHead className="text-right">İşlem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((m) => {
const isSelf = m.userId === currentUserId;
const isMemberOwner = m.role === "owner";
return (
<TableRow key={m.id}>
<TableCell className="font-medium">
{m.name}
{isSelf && (
<Badge variant="secondary" className="ml-2 text-xs">
Siz
</Badge>
)}
</TableCell>
<TableCell className="text-muted-foreground">{m.email}</TableCell>
<TableCell>
{isOwner && !isMemberOwner && !isSelf ? (
<Select
value={m.role}
disabled={busy === m.id}
onValueChange={(v) => setRole(m.id, v)}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Üye</SelectItem>
<SelectItem value="admin">Yönetici</SelectItem>
</SelectContent>
</Select>
) : (
<Badge variant={isMemberOwner ? "default" : "outline"}>
{ROLE_LABEL[m.role] ?? m.role}
</Badge>
)}
</TableCell>
<TableCell className="text-right">
{isSelf ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground"
disabled={busy === "leave"}
onClick={() => setLeaving(true)}
>
{busy === "leave" ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<DoorOpen className="size-3.5" />
)}
Ayrıl
</Button>
) : canManage && !isMemberOwner ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={busy === m.id}
onClick={() => setRemoving(m)}
>
{busy === m.id ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Trash2 className="size-3.5" />
)}
Çıkar
</Button>
) : null}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog
open={Boolean(removing)}
onOpenChange={(v) => !v && busy === null && setRemoving(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Üyeyi ekipten çıkar</DialogTitle>
<DialogDescription>
{removing && (
<>
<strong>{removing.name}</strong> ({removing.email}) ekipten çıkarılacak.
Verileri silinmez ama bu çalışma alanına erişimi kalkar.
</>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setRemoving(null)}
disabled={busy !== null}
>
Vazgeç
</Button>
<Button
variant="destructive"
onClick={handleRemove}
disabled={busy !== null}
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Çıkar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={leaving} onOpenChange={(v) => !v && busy === null && setLeaving(false)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Çalışma alanından ayrıl</DialogTitle>
<DialogDescription>
Bu çalışma alanındaki tüm verilere erişiminiz kalkar. Tekrar davet edilmedikçe
giremezsiniz. Devam etmek istediğinize emin misiniz?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setLeaving(false)}
disabled={busy !== null}
>
Vazgeç
</Button>
<Button
variant="destructive"
onClick={handleLeave}
disabled={busy !== null}
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <DoorOpen className="size-4" />}
Ayrıl
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function formDataFor(fields: Record<string, string>): FormData {
const fd = new FormData();
for (const [k, v] of Object.entries(fields)) fd.set(k, v);
return fd;
}
@@ -0,0 +1,138 @@
"use client";
import { useTransition, useState } from "react";
import { Check, Copy, Loader2, X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cancelInviteAction } from "@/lib/appwrite/team-actions";
type Invite = {
id: string;
code: string;
email: string;
role: string;
expiresAt?: string;
createdAt: string;
};
export function PendingInvitesTable({
invites,
canManage,
}: {
invites: Invite[];
canManage: boolean;
}) {
const [busy, setBusy] = useState<string | null>(null);
const [, startTransition] = useTransition();
const [copiedId, setCopiedId] = useState<string | null>(null);
const baseUrl =
typeof window !== "undefined" ? window.location.origin : "";
const copy = async (code: string, id: string) => {
try {
await navigator.clipboard.writeText(`${baseUrl}/d/${code}`);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
} catch {
/* ignore */
}
};
const cancel = (id: string) => {
setBusy(id);
startTransition(async () => {
const fd = new FormData();
fd.set("inviteId", id);
await cancelInviteAction({ ok: false }, fd);
setBusy(null);
});
};
const formatDate = (iso?: string) => {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("tr-TR", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
return (
<Card>
<CardHeader>
<CardTitle>Bekleyen davetler ({invites.length})</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Rol</TableHead>
<TableHead>Geçerlilik</TableHead>
<TableHead className="text-right">İşlem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invites.map((inv) => (
<TableRow key={inv.id}>
<TableCell className="font-medium">{inv.email}</TableCell>
<TableCell>
<Badge variant="outline">
{inv.role === "admin" ? "Yönetici" : "Üye"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(inv.expiresAt)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => copy(inv.code, inv.id)}
>
{copiedId === inv.id ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
Linki kopyala
</Button>
{canManage && (
<Button
type="button"
variant="ghost"
size="sm"
disabled={busy === inv.id}
onClick={() => cancel(inv.id)}
>
{busy === inv.id ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<X className="size-3.5" />
)}
İptal
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -0,0 +1,95 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Query } from "node-appwrite";
import { createAdminClient } from "@/lib/appwrite/server";
import { DATABASE_ID, TABLES, type InviteLink } from "@/lib/appwrite/schema";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { InviteForm } from "./components/invite-form";
import { MembersTable } from "./components/members-table";
import { PendingInvitesTable } from "./components/pending-invites-table";
export const metadata: Metadata = {
title: "DLS — Ekip üyeleri",
};
export default async function MembersPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const canManage = ctx.role === "owner" || ctx.role === "admin";
const isOwner = ctx.role === "owner";
const { teams, tablesDB } = createAdminClient();
const [memberships, invites] = await Promise.all([
teams.listMemberships(ctx.tenantId).catch(() => ({ memberships: [], total: 0 })),
tablesDB
.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.inviteLinks,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.equal("status", "pending"),
Query.orderDesc("$createdAt"),
Query.limit(50),
],
})
.catch(() => ({ rows: [] as unknown[] })),
]);
const members = memberships.memberships.map((m) => ({
id: m.$id,
userId: m.userId,
name: m.userName || m.userEmail,
email: m.userEmail,
role: m.roles[0] ?? "member",
joined: m.joined,
invited: m.invited,
confirm: m.confirm,
}));
const pendingInvites = (invites.rows as unknown as InviteLink[]).map((row) => ({
id: row.$id,
code: row.code,
email: row.email,
role: row.role ?? "member",
expiresAt: row.expiresAt,
createdAt: row.$createdAt,
}));
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">Ekip üyeleri</h1>
<p className="text-muted-foreground text-sm">
Çalışma alanına üye davet edin, rolleri yönetin.
</p>
</div>
{canManage ? (
<InviteForm />
) : (
<p className="text-muted-foreground text-sm">
Yeni üye davet etmek için yönetici yetkisine ihtiyacınız var.
</p>
)}
{pendingInvites.length > 0 && (
<PendingInvitesTable invites={pendingInvites} canManage={canManage} />
)}
<MembersTable
members={members}
currentUserId={ctx.user.id}
isOwner={isOwner}
canManage={canManage}
/>
</div>
);
}
@@ -0,0 +1,669 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Checkbox } from "@/components/ui/checkbox"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Bell, Mail, MessageSquare } from "lucide-react"
const notificationsFormSchema = z.object({
emailSecurity: z.boolean(),
emailUpdates: z.boolean(),
emailMarketing: z.boolean(),
pushMessages: z.boolean(),
pushMentions: z.boolean(),
pushTasks: z.boolean(),
emailFrequency: z.string(),
quietHoursStart: z.string(),
quietHoursEnd: z.string(),
channelEmail: z.boolean(),
channelPush: z.boolean(),
channelSms: z.boolean(),
// New notification table fields
orderUpdatesEmail: z.boolean(),
orderUpdatesBrowser: z.boolean(),
orderUpdatesApp: z.boolean(),
invoiceRemindersEmail: z.boolean(),
invoiceRemindersBrowser: z.boolean(),
invoiceRemindersApp: z.boolean(),
promotionalOffersEmail: z.boolean(),
promotionalOffersBrowser: z.boolean(),
promotionalOffersApp: z.boolean(),
systemMaintenanceEmail: z.boolean(),
systemMaintenanceBrowser: z.boolean(),
systemMaintenanceApp: z.boolean(),
notificationTiming: z.string(),
})
type NotificationsFormValues = z.infer<typeof notificationsFormSchema>
export default function NotificationSettings() {
const form = useForm<NotificationsFormValues>({
resolver: zodResolver(notificationsFormSchema),
defaultValues: {
emailSecurity: false,
emailUpdates: true,
emailMarketing: false,
pushMessages: true,
pushMentions: true,
pushTasks: false,
emailFrequency: "instant",
quietHoursStart: "22:00",
quietHoursEnd: "06:00",
channelEmail: true,
channelPush: true,
channelSms: false,
// New notification table defaults
orderUpdatesEmail: true,
orderUpdatesBrowser: true,
orderUpdatesApp: true,
invoiceRemindersEmail: true,
invoiceRemindersBrowser: false,
invoiceRemindersApp: true,
promotionalOffersEmail: false,
promotionalOffersBrowser: true,
promotionalOffersApp: false,
systemMaintenanceEmail: true,
systemMaintenanceBrowser: true,
systemMaintenanceApp: false,
notificationTiming: "online",
},
})
function onSubmit(data: NotificationsFormValues) {
console.log("Notifications settings submitted:", data)
// Here you would typically save the settings
}
return (
<div className="space-y-6 px-4 lg:px-6">
<div>
<h1 className="text-3xl font-bold">Notifications</h1>
<p className="text-muted-foreground">
Configure how you receive notifications.
</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid gap-6 grid-cols-1 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Email Notifications</CardTitle>
<CardDescription>
Choose what email notifications you want to receive.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<FormField
control={form.control}
name="emailSecurity"
render={({ field }) => (
<FormItem className="flex items-center space-x-3">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1">
<FormLabel>Security alerts</FormLabel>
<p className="text-sm text-muted-foreground">
Get notified when there are security events on your account.
</p>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailUpdates"
render={({ field }) => (
<FormItem className="flex items-center space-x-3">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1">
<FormLabel>Product updates</FormLabel>
<p className="text-sm text-muted-foreground">
Receive updates about new features and improvements.
</p>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailMarketing"
render={({ field }) => (
<FormItem className="flex items-center space-x-3">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1">
<FormLabel>Marketing emails</FormLabel>
<p className="text-sm text-muted-foreground">
Receive emails about our latest offers and promotions.
</p>
</div>
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Push Notifications</CardTitle>
<CardDescription>
Configure browser and mobile push notifications.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<FormField
control={form.control}
name="pushMessages"
render={({ field }) => (
<FormItem className="flex items-center space-x-3">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1">
<FormLabel>New messages</FormLabel>
<p className="text-sm text-muted-foreground">
Get notified when you receive new messages.
</p>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="pushMentions"
render={({ field }) => (
<FormItem className="flex items-center space-x-3">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1">
<FormLabel>Mentions</FormLabel>
<p className="text-sm text-muted-foreground">
Get notified when someone mentions you.
</p>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="pushTasks"
render={({ field }) => (
<FormItem className="flex items-center space-x-3">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1">
<FormLabel>Task updates</FormLabel>
<p className="text-sm text-muted-foreground">
Get notified about task assignments and updates.
</p>
</div>
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Notification Frequency</CardTitle>
<CardDescription>
Control how often you receive notifications.
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
control={form.control}
name="emailFrequency"
render={({ field }) => (
<FormItem>
<FormLabel>Email Frequency</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="instant">Instant</SelectItem>
<SelectItem value="hourly">Hourly digest</SelectItem>
<SelectItem value="daily">Daily digest</SelectItem>
<SelectItem value="weekly">Weekly digest</SelectItem>
<SelectItem value="never">Never</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>Quiet Hours</FormLabel>
<div className="flex space-x-2">
<FormField
control={form.control}
name="quietHoursStart"
render={({ field }) => (
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-50">
<SelectValue placeholder="Start" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="22:00">10:00 PM</SelectItem>
<SelectItem value="23:00">11:00 PM</SelectItem>
<SelectItem value="00:00">12:00 AM</SelectItem>
</SelectContent>
</Select>
)}
/>
<span className="self-center">to</span>
<FormField
control={form.control}
name="quietHoursEnd"
render={({ field }) => (
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-50">
<SelectValue placeholder="End" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="06:00">6:00 AM</SelectItem>
<SelectItem value="07:00">7:00 AM</SelectItem>
<SelectItem value="08:00">8:00 AM</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
</FormItem>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Notification Preferences</CardTitle>
<CardDescription>
We need permission from your browser to show notifications.{" "}
<Button variant="link" className="p-0 h-auto text-primary">
Request Permission
</Button>
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">TYPE</TableHead>
<TableHead className="text-center">EMAIL</TableHead>
<TableHead className="text-center">BROWSER</TableHead>
<TableHead className="text-center">APP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">Order updates</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="orderUpdatesEmail"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="orderUpdatesBrowser"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="orderUpdatesApp"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Invoice reminders</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="invoiceRemindersEmail"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="invoiceRemindersBrowser"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="invoiceRemindersApp"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Promotional offers</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="promotionalOffersEmail"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="promotionalOffersBrowser"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="promotionalOffersApp"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">System maintenance</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="systemMaintenanceEmail"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="systemMaintenanceBrowser"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
<TableCell className="text-center">
<FormField
control={form.control}
name="systemMaintenanceApp"
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
<div className="space-y-4">
<FormField
control={form.control}
name="notificationTiming"
render={({ field }) => (
<FormItem>
<FormLabel>When should we send you notifications?</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-full max-w-sm">
<SelectValue placeholder="Select timing" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="online">Only When I&apos;m online</SelectItem>
<SelectItem value="always">Always</SelectItem>
<SelectItem value="never">Never</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Notification Channels</CardTitle>
<CardDescription>
Choose your preferred notification channels for different types of alerts.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<FormField
control={form.control}
name="channelEmail"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Mail className="h-5 w-5 text-muted-foreground" />
<div>
<FormLabel className="font-medium mb-1">Email</FormLabel>
<div className="text-sm text-muted-foreground">Receive notifications via email</div>
</div>
</div>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Separator />
<FormField
control={form.control}
name="channelPush"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Bell className="h-5 w-5 text-muted-foreground" />
<div>
<FormLabel className="font-medium mb-1">Push Notifications</FormLabel>
<div className="text-sm text-muted-foreground">Receive browser push notifications</div>
</div>
</div>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Separator />
<FormField
control={form.control}
name="channelSms"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<MessageSquare className="h-5 w-5 text-muted-foreground" />
<div>
<FormLabel className="font-medium mb-1">SMS</FormLabel>
<div className="text-sm text-muted-foreground">Receive notifications via SMS</div>
</div>
</div>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
<div className="flex space-x-2">
<Button type="submit" className="cursor-pointer">Save Preferences</Button>
<Button variant="outline" type="reset" className="cursor-pointer">Cancel</Button>
</div>
</form>
</Form>
</div>
)
}
+361
View File
@@ -0,0 +1,361 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Card, CardContent,CardHeader, CardDescription, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { Upload } from "lucide-react"
import { useRef, useState } from "react"
import { Separator } from "@/components/ui/separator"
import { Logo } from "@/components/logo"
const userFormSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
phone: z.string().optional(),
website: z.string().optional(),
location: z.string().optional(),
role: z.string().optional(),
bio: z.string().optional(),
company: z.string().optional(),
timezone: z.string().optional(),
language: z.string().optional(),
})
type UserFormValues = z.infer<typeof userFormSchema>
export default function UserSettingsPage() {
const fileInputRef = useRef<HTMLInputElement>(null)
const [profileImage, setProfileImage] = useState<string | null>(null)
const [useDefaultIcon, setUseDefaultIcon] = useState(true)
const form = useForm<UserFormValues>({
resolver: zodResolver(userFormSchema),
defaultValues: {
firstName: "",
lastName: "",
email: "",
phone: "",
website: "",
location: "",
role: "",
bio: "",
company: "",
timezone: "",
language: "",
},
})
function onSubmit(data: UserFormValues) {
console.log("Form submitted:", data)
// Here you would typically save the data
}
const handleFileUpload = () => {
fileInputRef.current?.click()
}
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
const reader = new FileReader()
reader.onload = (e) => {
setProfileImage(e.target?.result as string)
setUseDefaultIcon(false)
}
reader.readAsDataURL(file)
}
}
const handleReset = () => {
setProfileImage(null)
setUseDefaultIcon(true)
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
return (
<div className="px-4 lg:px-6">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader>
<CardTitle>Profile Settings</CardTitle>
<CardDescription>Update your personal information and preferences</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Profile Picture Section */}
<div className="flex items-center gap-6 ">
{useDefaultIcon ? (
<div className="flex h-20 w-20 items-center justify-center rounded-lg">
< Logo size={56} />
</div>
) : (
<Avatar className="h-20 w-20 rounded-lg">
<AvatarImage src={profileImage || undefined} />
<AvatarFallback>SS</AvatarFallback>
</Avatar>
)}
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Button
variant="default"
size="sm"
onClick={handleFileUpload}
className="cursor-pointer"
>
<Upload className="mr-2 h-4 w-4" />
Upload new photo
</Button>
<Button
variant="outline"
size="sm"
onClick={handleReset}
className="cursor-pointer"
>
Reset
</Button>
</div>
<p className="text-xs text-muted-foreground">
Allowed JPG, GIF or PNG. Max size of 800K
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/gif,image/png"
onChange={handleFileChange}
className="hidden"
/>
</div>
<Separator className="mb-10" />
{/* Form Fields */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* First Name */}
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input placeholder="Enter your first name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Last Name */}
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input placeholder="Enter your last name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Email */}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>E-mail</FormLabel>
<FormControl>
<Input type="email" placeholder="Enter your email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Company */}
<FormField
control={form.control}
name="company"
render={({ field }) => (
<FormItem>
<FormLabel>Company</FormLabel>
<FormControl>
<Input placeholder="Enter your company" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Phone Number */}
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone Number</FormLabel>
<FormControl>
<Input type="tel" placeholder="Enter your phone number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Location */}
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Location</FormLabel>
<FormControl>
<Input placeholder="Enter your location" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Website */}
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem>
<FormLabel>Website</FormLabel>
<FormControl>
<Input type="url" placeholder="Enter your website" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Language */}
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>Language</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select Language" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="english">English</SelectItem>
<SelectItem value="spanish">Spanish</SelectItem>
<SelectItem value="french">French</SelectItem>
<SelectItem value="german">German</SelectItem>
<SelectItem value="italian">Italian</SelectItem>
<SelectItem value="portuguese">Portuguese</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Role */}
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<FormControl>
<Input placeholder="Enter your role" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Timezone */}
<FormField
control={form.control}
name="timezone"
render={({ field }) => (
<FormItem>
<FormLabel>Timezone</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select Timezone" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="pst">PST (Pacific Standard Time)</SelectItem>
<SelectItem value="est">EST (Eastern Standard Time)</SelectItem>
<SelectItem value="cst">CST (Central Standard Time)</SelectItem>
<SelectItem value="mst">MST (Mountain Standard Time)</SelectItem>
<SelectItem value="utc">UTC (Coordinated Universal Time)</SelectItem>
<SelectItem value="cet">CET (Central European Time)</SelectItem>
<SelectItem value="jst">JST (Japan Standard Time)</SelectItem>
<SelectItem value="aest">AEST (Australian Eastern Standard Time)</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Bio - Full Width */}
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
placeholder="Tell us a little about yourself..."
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Action Buttons */}
<div className="flex justify-start gap-3">
<Button type="submit" className="cursor-pointer">
Save Changes
</Button>
<Button variant="outline" type="button" className="cursor-pointer">
Cancel
</Button>
</div>
</CardContent>
</Card>
</form>
</Form>
</div>
)
}
@@ -0,0 +1,211 @@
"use client";
import { useActionState, useEffect, useRef, useState, useTransition } from "react";
import { Building2, ImagePlus, Loader2, Trash2, Upload } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import {
removeLogoAction,
uploadLogoAction,
} from "@/lib/appwrite/logo-actions";
import { initialLogoState } from "@/lib/appwrite/logo-types";
type Props = {
canEdit: boolean;
currentLogoUrl: string | null;
companyName: string;
};
const MAX_BYTES = 2 * 1024 * 1024;
const ALLOWED_MIME = ["image/png", "image/jpeg", "image/webp", "image/svg+xml"];
export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
const [state, formAction, isPending] = useActionState(
uploadLogoAction,
initialLogoState,
);
const [removing, startRemove] = useTransition();
const [previewUrl, setPreviewUrl] = useState<string | null>(currentLogoUrl);
const [dragOver, setDragOver] = useState(false);
const [selectedName, setSelectedName] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setPreviewUrl(currentLogoUrl);
}, [currentLogoUrl]);
useEffect(() => {
if (state.ok) {
toast.success("Logo güncellendi.");
setSelectedName(null);
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
const handleFile = (file: File | null) => {
if (!file) return;
if (!ALLOWED_MIME.includes(file.type)) {
toast.error("Sadece PNG, JPG, WebP veya SVG yükleyebilirsiniz.");
return;
}
if (file.size > MAX_BYTES) {
toast.error("Dosya 2MB'dan büyük olamaz.");
return;
}
setSelectedName(file.name);
const reader = new FileReader();
reader.onload = (e) => {
setPreviewUrl(typeof e.target?.result === "string" ? e.target.result : null);
};
reader.readAsDataURL(file);
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file && inputRef.current) {
const dt = new DataTransfer();
dt.items.add(file);
inputRef.current.files = dt.files;
handleFile(file);
}
};
const handleRemove = () => {
startRemove(async () => {
const result = await removeLogoAction();
if (result.ok) {
toast.success("Logo kaldırıldı.");
setPreviewUrl(null);
setSelectedName(null);
if (inputRef.current) inputRef.current.value = "";
} else {
toast.error(result.error ?? "Logo kaldırılamadı.");
}
});
};
const submitDisabled = isPending || removing || !selectedName;
const busy = isPending || removing;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="size-4" />
Logo
</CardTitle>
<CardDescription>
Faturalarda, panel başlığında ve dış paylaşımlarda görünür. PNG, JPG, WebP veya SVG
en fazla 2 MB.
</CardDescription>
</CardHeader>
<CardContent>
<form ref={formRef} action={formAction} className="space-y-4">
<div className="grid items-start gap-6 md:grid-cols-[200px_1fr]">
<div className="bg-muted flex aspect-square w-full items-center justify-center overflow-hidden rounded-lg border">
{previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewUrl}
alt={`${companyName} logo`}
className="size-full object-contain"
/>
) : (
<div className="text-muted-foreground flex flex-col items-center gap-2 p-4 text-center text-xs">
<Building2 className="size-8 opacity-40" />
<span>Henüz logo yok</span>
</div>
)}
</div>
<div className="space-y-3">
<label
onDragOver={(e) => {
e.preventDefault();
if (canEdit) setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={canEdit ? handleDrop : undefined}
className={cn(
"flex min-h-[120px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 text-center transition-colors",
dragOver && "border-primary bg-primary/5",
!canEdit && "cursor-not-allowed opacity-60",
!dragOver && "hover:bg-muted/30",
)}
>
<input
ref={inputRef}
type="file"
name="logo"
accept={ALLOWED_MIME.join(",")}
className="sr-only"
disabled={!canEdit || busy}
onChange={(e) => handleFile(e.target.files?.[0] ?? null)}
/>
<ImagePlus className="text-muted-foreground size-6" />
<div className="text-sm font-medium">
{selectedName ?? "Logo yüklemek için tıkla veya sürükle bırak"}
</div>
<div className="text-muted-foreground text-xs">
Önerilen: kare, en az 256×256 px, şeffaf arka plan (PNG/SVG)
</div>
</label>
<div className="flex flex-wrap gap-2">
{canEdit && (
<Button type="submit" disabled={submitDisabled}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Yükleniyor...
</>
) : (
<>
<Upload className="size-4" />
Yükle
</>
)}
</Button>
)}
{canEdit && currentLogoUrl && (
<Button
type="button"
variant="outline"
onClick={handleRemove}
disabled={busy}
className="text-destructive hover:text-destructive"
>
{removing ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
Kaldır
</Button>
)}
{!canEdit && (
<p className="text-muted-foreground text-xs">
Logo değiştirmek için yönetici yetkisi gerekli.
</p>
)}
</div>
</div>
</div>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,176 @@
"use client";
import { useActionState, useEffect } from "react";
import { Building2, Coins, Loader2, Save } from "lucide-react";
import { toast } from "sonner";
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 { Textarea } from "@/components/ui/textarea";
import { updateWorkspaceSettingsAction } from "@/lib/appwrite/workspace-actions";
import { initialWorkspaceSettingsState } from "@/lib/appwrite/workspace-types";
type Defaults = {
companyName: string;
companyTaxId: string;
companyAddress: string;
companyEmail: string;
companyPhone: string;
defaultCurrency: string;
kind: "lab" | "clinic" | null;
memberNumber: string;
};
export function WorkspaceSettingsForm({
canEdit,
defaults,
}: {
canEdit: boolean;
defaults: Defaults;
}) {
const [state, formAction, isPending] = useActionState(
updateWorkspaceSettingsAction,
initialWorkspaceSettingsState,
);
useEffect(() => {
if (state.ok) toast.success("Bilgiler güncellendi.");
else if (state.error) toast.error(state.error);
}, [state]);
const kindLabel = defaults.kind === "lab" ? "Laboratuvar" : defaults.kind === "clinic" ? "Klinik" : "—";
return (
<form action={formAction} className="space-y-6">
<fieldset disabled={!canEdit || isPending} className="space-y-6 disabled:opacity-90">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="size-4" />
Şirket
</CardTitle>
<CardDescription>Resmi şirket bilgileriniz ve bağlantı kodunuz.</CardDescription>
</CardHeader>
<CardContent className="grid gap-5 md:grid-cols-2">
<div className="grid gap-2">
<Label>Hesap türü</Label>
<div className="bg-muted/50 flex h-9 items-center rounded-md border px-3 text-sm">
{kindLabel}
</div>
</div>
<div className="grid gap-2">
<Label>Bağlantı kodu</Label>
<div className="bg-muted/50 flex h-9 items-center rounded-md border px-3 font-mono text-sm tracking-widest">
{defaults.memberNumber || "—"}
</div>
</div>
<div className="md:col-span-2 grid gap-2">
<Label htmlFor="companyName">Şirket adı *</Label>
<Input
id="companyName"
name="companyName"
defaultValue={defaults.companyName}
required
placeholder="Örn. Atlas Diş Polikliniği"
/>
{state.fieldErrors?.companyName && (
<p className="text-destructive text-xs">{state.fieldErrors.companyName}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="companyTaxId">Vergi numarası</Label>
<Input
id="companyTaxId"
name="companyTaxId"
defaultValue={defaults.companyTaxId}
inputMode="numeric"
placeholder="1234567890"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="companyPhone">Telefon</Label>
<Input
id="companyPhone"
name="companyPhone"
type="tel"
defaultValue={defaults.companyPhone}
placeholder="+90 555 123 45 67"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="companyEmail">Email</Label>
<Input
id="companyEmail"
name="companyEmail"
type="email"
defaultValue={defaults.companyEmail}
placeholder="info@firma.com"
/>
{state.fieldErrors?.companyEmail && (
<p className="text-destructive text-xs">{state.fieldErrors.companyEmail}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="companyAddress">Adres</Label>
<Textarea
id="companyAddress"
name="companyAddress"
rows={2}
defaultValue={defaults.companyAddress}
placeholder="İl, ilçe, açık adres"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Coins className="size-4" />
Finans varsayılanları
</CardTitle>
<CardDescription>Yeni ve faturalarda kullanılan varsayılanlar.</CardDescription>
</CardHeader>
<CardContent className="grid gap-5 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="defaultCurrency">Varsayılan para birimi</Label>
<Input
id="defaultCurrency"
name="defaultCurrency"
defaultValue={defaults.defaultCurrency}
maxLength={8}
placeholder="TRY"
style={{ textTransform: "uppercase" }}
/>
</div>
</CardContent>
</Card>
{canEdit && (
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
Kaydet
</>
)}
</Button>
</div>
)}
</fieldset>
</form>
);
}
@@ -0,0 +1,55 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getLogoUrl } from "@/lib/appwrite/storage";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { LogoUploader } from "./components/logo-uploader";
import { WorkspaceSettingsForm } from "./components/workspace-form";
export const metadata: Metadata = {
title: "DLS — Şirket bilgileri",
};
export default async function WorkspaceSettingsPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const canEdit = ctx.role === "owner" || ctx.role === "admin";
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">Şirket bilgileri</h1>
<p className="text-muted-foreground text-sm">
Faturalarda ve panel başlığında görünecek şirket bilgileri.
{!canEdit && " Düzenlemek için yönetici yetkisine ihtiyacınız var."}
</p>
</div>
<LogoUploader
canEdit={canEdit}
currentLogoUrl={getLogoUrl(ctx.settings?.logo)}
companyName={ctx.settings?.companyName ?? "Çalışma alanı"}
/>
<WorkspaceSettingsForm
canEdit={canEdit}
defaults={{
companyName: ctx.settings?.companyName ?? "",
companyTaxId: ctx.settings?.companyTaxId ?? "",
companyAddress: ctx.settings?.companyAddress ?? "",
companyEmail: ctx.settings?.companyEmail ?? "",
companyPhone: ctx.settings?.companyPhone ?? "",
defaultCurrency: ctx.settings?.defaultCurrency ?? "TRY",
kind: ctx.settings?.kind ?? null,
memberNumber: ctx.settings?.memberNumber ?? "",
}}
/>
</div>
);
}