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>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
import { DATABASE_ID, TABLES, type TenantSettings } from "./schema";
import { createAdminClient } from "./server";
import { requireRole, requireTenant } from "./tenant-guard";
import type { WorkspaceSettingsState } from "./workspace-types";
import { workspaceSettingsSchema } from "@/lib/validation/workspace";
function appwriteError(e: unknown): string {
if (e instanceof AppwriteException) return e.message || "Beklenmeyen hata.";
return "Bağlantı hatası. Tekrar deneyin.";
}
function flattenErrors(err: z.ZodError): Record<string, string> {
const out: Record<string, string> = {};
for (const issue of err.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
function pickFormFields(formData: FormData) {
return {
companyName: String(formData.get("companyName") ?? "").trim(),
companyTaxId: String(formData.get("companyTaxId") ?? "").trim(),
companyAddress: String(formData.get("companyAddress") ?? "").trim(),
companyEmail: String(formData.get("companyEmail") ?? "").trim(),
companyPhone: String(formData.get("companyPhone") ?? "").trim(),
defaultVatRate: String(formData.get("defaultVatRate") ?? "20"),
invoicePrefix: String(formData.get("invoicePrefix") ?? "").trim(),
};
}
export async function updateWorkspaceSettingsAction(
_prev: WorkspaceSettingsState,
formData: FormData,
): Promise<WorkspaceSettingsState> {
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
} catch {
return { ok: false, error: "Düzenleme yetkiniz yok." };
}
const parsed = workspaceSettingsSchema.safeParse(pickFormFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const existing = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", ctx.tenantId), Query.limit(1)],
});
const row = existing.rows[0] as unknown as TenantSettings | undefined;
if (row) {
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, row.$id, parsed.data);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "tenant_settings",
entityId: row.$id,
changes: parsed.data,
});
} else {
const created = await tablesDB.createRow(
DATABASE_ID,
TABLES.tenantSettings,
ID.unique(),
{ tenantId: ctx.tenantId, ...parsed.data },
[
Permission.read(Role.team(ctx.tenantId)),
Permission.update(Role.team(ctx.tenantId, "owner")),
Permission.update(Role.team(ctx.tenantId, "admin")),
Permission.delete(Role.team(ctx.tenantId, "owner")),
],
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "tenant_settings",
entityId: created.$id,
changes: parsed.data,
});
}
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
// Tenant name shows up everywhere — revalidate broadly
revalidatePath("/", "layout");
return { ok: true };
}
+7
View File
@@ -0,0 +1,7 @@
export type WorkspaceSettingsState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
};
export const initialWorkspaceSettingsState: WorkspaceSettingsState = { ok: false };
+44
View File
@@ -0,0 +1,44 @@
import { z } from "zod";
export const workspaceSettingsSchema = z.object({
companyName: z.string().trim().min(1, "Şirket adı zorunlu.").max(255),
companyTaxId: z
.string()
.trim()
.max(50)
.optional()
.transform((v) => (v ? v : undefined)),
companyAddress: z
.string()
.trim()
.max(500)
.optional()
.transform((v) => (v ? v : undefined)),
companyEmail: z
.union([z.string().email("Geçerli bir email girin."), z.literal("")])
.optional()
.transform((v) => (v ? v : undefined)),
companyPhone: z
.string()
.trim()
.max(30)
.optional()
.transform((v) => (v ? v : undefined)),
defaultVatRate: z
.union([z.number(), z.string()])
.optional()
.transform((v) => {
if (v === undefined || v === "") return 20;
const n = typeof v === "string" ? Number(v.replace(",", ".")) : v;
return Number.isFinite(n) ? n : 20;
})
.pipe(z.number().min(0, "Negatif olamaz.").max(100, "100'den büyük olamaz.")),
invoicePrefix: z
.string()
.trim()
.max(10)
.optional()
.transform((v) => (v ? v.toUpperCase() : "INV")),
});
export type WorkspaceSettingsInput = z.infer<typeof workspaceSettingsSchema>;