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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 iş ö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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 iş listesi, filtreleme ve detay görünümü sonraki sürümde eklenecek.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 iş listesi sonraki sürümde eklenecek.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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 iş 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user