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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type WorkspaceSettingsState = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
fieldErrors?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initialWorkspaceSettingsState: WorkspaceSettingsState = { ok: false };
|
||||||
@@ -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>;
|
||||||
Reference in New Issue
Block a user