feat(settings): /settings/workspace — edit company info + invoice defaults

Owner/admin edit, member read-only.

Schema/validation:
- lib/validation/workspace.ts (workspaceSettingsSchema)
- lib/appwrite/workspace-actions.ts:
  * updateWorkspaceSettingsAction — requireRole owner|admin, upserts the
    tenant_settings row (creates one with team-scoped perms if absent,
    e.g. for tenants created before tenant_settings was a table; just
    defense). Audit-logged.
  * Forces invoicePrefix to uppercase. defaultVatRate clamped to [0, 100].
  * revalidatePath('/', 'layout') so the new company name updates in
    sidebar header and dashboard greeting on next render.

UI:
- /settings/workspace page (server) — pulls active tenant settings
  via requireTenant, shows form pre-filled.
- WorkspaceSettingsForm: 2 cards
  * Şirket — name (required), tax id, phone, email, address
  * Faturalama — invoicePrefix, defaultVatRate, read-only invoiceCounter
- All inputs disabled if user is a member (canEdit=false). Submit button
  hidden in that case. Description on the page changes accordingly.
- Toast feedback for success/error.

Skipped: logo upload (storage bucket pending). Will revisit.
This commit is contained in:
kovakmedya
2026-04-30 06:44:11 +03:00
parent 02a02ba9e6
commit fc091b9e0d
5 changed files with 399 additions and 0 deletions
@@ -0,0 +1,192 @@
"use client";
import { useActionState, useEffect } from "react";
import { Building2, Loader2, Receipt, 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;
defaultVatRate: number;
invoicePrefix: string;
invoiceCounter: number;
};
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]);
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.</CardDescription>
</CardHeader>
<CardContent className="grid gap-5 md:grid-cols-2">
<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. Acme Yazılım Ltd. Şti."
/>
{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">
<Receipt className="size-4" />
Faturalama
</CardTitle>
<CardDescription>
Yeni fatura oluştururken kullanılan varsayılanlar.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-5 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="invoicePrefix">Fatura ön eki</Label>
<Input
id="invoicePrefix"
name="invoicePrefix"
defaultValue={defaults.invoicePrefix}
maxLength={10}
placeholder="INV"
style={{ textTransform: "uppercase" }}
/>
<p className="text-muted-foreground text-xs">
Örn. <code>INV-2026-0001</code>
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="defaultVatRate">Varsayılan KDV %</Label>
<Input
id="defaultVatRate"
name="defaultVatRate"
type="number"
step="0.1"
min="0"
max="100"
defaultValue={defaults.defaultVatRate}
/>
{state.fieldErrors?.defaultVatRate && (
<p className="text-destructive text-xs">{state.fieldErrors.defaultVatRate}</p>
)}
</div>
<div className="grid gap-2">
<Label>Sayaç</Label>
<div className="bg-muted/50 flex h-9 items-center rounded-md border px-3 text-sm tabular-nums">
{defaults.invoiceCounter}
</div>
<p className="text-muted-foreground text-xs">
Bir sonraki fatura numarası: bu sayı + 1
</p>
</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,50 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { WorkspaceSettingsForm } from "./components/workspace-form";
export const metadata: Metadata = {
title: "İşletmem — Ş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>
<WorkspaceSettingsForm
canEdit={canEdit}
defaults={{
companyName: ctx.settings?.companyName ?? "",
companyTaxId: ctx.settings?.companyTaxId ?? "",
companyAddress: ctx.settings?.companyAddress ?? "",
companyEmail: ctx.settings?.companyEmail ?? "",
companyPhone: ctx.settings?.companyPhone ?? "",
defaultVatRate:
typeof ctx.settings?.defaultVatRate === "number"
? ctx.settings.defaultVatRate
: 20,
invoicePrefix: ctx.settings?.invoicePrefix ?? "INV",
invoiceCounter: ctx.settings?.invoiceCounter ?? 0,
}}
/>
</div>
);
}