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