init: kovakemlak-crm project scaffold

- Next.js 16 + Appwrite multi-tenant emlak CRM
- Database: kovakemlak-db (properties, customers, customer_searches, property_matches, presentations, investors, activities, tenant_settings)
- Same stack as isletmem-kovakcrm (shadcn/ui template base)
- Modules: portföy, müşteri takibi, arama kriterleri, otomatik eşleştirme, sunum linki, yatırımcı portalı
This commit is contained in:
egecankomur
2026-05-05 04:37:04 +03:00
commit 37679e83e6
383 changed files with 53525 additions and 0 deletions
+620
View File
@@ -0,0 +1,620 @@
"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 Invoice,
type InvoiceItem,
type TenantSettings,
} from "./schema";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
import type { InvoiceActionState } from "./invoice-types";
import { invoiceItemSchema, invoiceSchema } from "@/lib/validation/invoices";
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 teamRowPermissions(tenantId: string) {
return [
Permission.read(Role.team(tenantId)),
Permission.update(Role.team(tenantId)),
Permission.delete(Role.team(tenantId, "owner")),
Permission.delete(Role.team(tenantId, "admin")),
];
}
function toIso(v: string): string {
if (!v) return v;
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return `${v}T00:00:00.000+00:00`;
return v;
}
async function nextInvoiceNumber(
tenantId: string,
): Promise<{ number: string; settingsId: string | null }> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
});
const settings = result.rows[0] as unknown as TenantSettings | undefined;
const prefix = settings?.invoicePrefix || "INV";
const counter = (settings?.invoiceCounter ?? 0) + 1;
const year = new Date().getFullYear();
const number = `${prefix}-${year}-${String(counter).padStart(4, "0")}`;
if (settings) {
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, settings.$id, {
invoiceCounter: counter,
});
return { number, settingsId: settings.$id };
}
return { number, settingsId: null };
}
/**
* Reflect invoice payment status into finance_entries.
* - status === "paid": ensure exactly one income entry exists for this invoice.
* - otherwise: remove any income entries linked to this invoice (auto-generated).
*
* Best-effort. Failures here must not break the invoice mutation; we log the
* error to audit but do not throw.
*/
async function syncPaymentEntry(
tenantId: string,
userId: string,
invoice: Invoice,
): Promise<void> {
try {
const { tablesDB } = createAdminClient();
const linked = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.financeEntries,
queries: [
Query.equal("tenantId", tenantId),
Query.equal("invoiceId", invoice.$id),
Query.equal("type", "income"),
Query.limit(10),
],
});
if (invoice.status === "paid") {
if (linked.rows.length === 0) {
const row = await tablesDB.createRow(
DATABASE_ID,
TABLES.financeEntries,
ID.unique(),
{
tenantId,
createdBy: userId,
type: "income",
amount: Number((invoice.total ?? 0).toFixed(2)),
date: new Date().toISOString(),
description: `Fatura ${invoice.number} tahsilatı`,
customerId: invoice.customerId,
invoiceId: invoice.$id,
scope: "company",
},
[
Permission.read(Role.team(tenantId)),
Permission.update(Role.team(tenantId)),
Permission.delete(Role.team(tenantId, "owner")),
Permission.delete(Role.team(tenantId, "admin")),
],
);
await logAudit({
tenantId,
userId,
action: "create",
entityType: "finance_entry",
entityId: row.$id,
changes: { auto: "invoice_paid", invoiceId: invoice.$id, amount: invoice.total },
});
} else {
// Keep the first; resync amount in case the invoice total changed
const first = linked.rows[0];
const desiredAmount = Number((invoice.total ?? 0).toFixed(2));
if (
(first as unknown as { amount: number }).amount !== desiredAmount
) {
await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, first.$id, {
amount: desiredAmount,
});
}
}
} else {
for (const r of linked.rows) {
await tablesDB.deleteRow(DATABASE_ID, TABLES.financeEntries, r.$id);
await logAudit({
tenantId,
userId,
action: "delete",
entityType: "finance_entry",
entityId: r.$id,
changes: { auto: "invoice_unpaid", invoiceId: invoice.$id },
});
}
}
} catch {
// best-effort; don't block the invoice update
}
}
async function recomputeTotals(
tenantId: string,
invoiceId: string,
userId?: string,
): Promise<{ subtotal: number; vatTotal: number; total: number }> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.invoiceItems,
queries: [
Query.equal("tenantId", tenantId),
Query.equal("invoiceId", invoiceId),
Query.limit(500),
],
});
let subtotal = 0;
let vatTotal = 0;
for (const r of result.rows as unknown as InvoiceItem[]) {
const lineNet = (r.quantity ?? 0) * (r.unitPrice ?? 0);
const lineVat = lineNet * ((r.vatRate ?? 0) / 100);
subtotal += lineNet;
vatTotal += lineVat;
}
const total = subtotal + vatTotal;
await tablesDB.updateRow(DATABASE_ID, TABLES.invoices, invoiceId, {
subtotal: Number(subtotal.toFixed(2)),
vatTotal: Number(vatTotal.toFixed(2)),
total: Number(total.toFixed(2)),
});
// If invoice is paid, keep the linked income entry's amount in sync.
if (userId) {
try {
const refreshed = (await tablesDB.getRow(
DATABASE_ID,
TABLES.invoices,
invoiceId,
)) as unknown as Invoice;
if (refreshed.status === "paid") {
await syncPaymentEntry(tenantId, userId, refreshed);
}
} catch {
/* best-effort */
}
}
return { subtotal, vatTotal, total };
}
// -------------------- Invoice header --------------------
function pickInvoiceFields(formData: FormData) {
return {
customerId: String(formData.get("customerId") ?? ""),
issueDate: String(formData.get("issueDate") ?? ""),
dueDate: String(formData.get("dueDate") ?? ""),
status:
(formData.get("status") as "draft" | "sent" | "paid" | "overdue" | "cancelled" | null) ??
"draft",
notes: String(formData.get("notes") ?? "").trim(),
};
}
export async function createInvoiceAction(
_prev: InvoiceActionState,
formData: FormData,
): Promise<InvoiceActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = invoiceSchema.safeParse(pickInvoiceFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
let invoiceId = "";
try {
const { tablesDB } = createAdminClient();
const { number } = await nextInvoiceNumber(ctx.tenantId);
const data = {
...parsed.data,
issueDate: toIso(parsed.data.issueDate),
dueDate: toIso(parsed.data.dueDate),
number,
subtotal: 0,
vatTotal: 0,
total: 0,
};
const row = await tablesDB.createRow(
DATABASE_ID,
TABLES.invoices,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
...data,
},
teamRowPermissions(ctx.tenantId),
);
invoiceId = row.$id;
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "invoice",
entityId: row.$id,
changes: { number, customerId: parsed.data.customerId },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/invoices");
return { ok: true, invoiceId };
}
export async function updateInvoiceAction(
_prev: InvoiceActionState,
formData: FormData,
): Promise<InvoiceActionState> {
const id = String(formData.get("id") ?? "");
if (!id) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = invoiceSchema.safeParse(pickInvoiceFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.invoices,
id,
)) as unknown as Invoice;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
const data = {
...parsed.data,
issueDate: toIso(parsed.data.issueDate),
dueDate: toIso(parsed.data.dueDate),
};
await tablesDB.updateRow(DATABASE_ID, TABLES.invoices, id, data);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "invoice",
entityId: id,
changes: data,
});
// Sync payment ↔ finance entry on every save (cheap; idempotent).
const refreshed = (await tablesDB.getRow(
DATABASE_ID,
TABLES.invoices,
id,
)) as unknown as Invoice;
await syncPaymentEntry(ctx.tenantId, ctx.user.id, refreshed);
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/invoices");
revalidatePath(`/invoices/${id}`);
revalidatePath("/finance");
return { ok: true, invoiceId: id };
}
export async function deleteInvoiceAction(
formData: FormData,
): Promise<InvoiceActionState> {
const id = String(formData.get("id") ?? "");
if (!id) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.invoices,
id,
)) as unknown as Invoice;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
// Delete items first
const items = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.invoiceItems,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.equal("invoiceId", id),
Query.limit(500),
],
});
for (const it of items.rows) {
await tablesDB.deleteRow(DATABASE_ID, TABLES.invoiceItems, it.$id);
}
// Cascade-delete any auto-generated income entries linked to this invoice
const linkedEntries = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.financeEntries,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.equal("invoiceId", id),
Query.limit(50),
],
});
for (const r of linkedEntries.rows) {
await tablesDB.deleteRow(DATABASE_ID, TABLES.financeEntries, r.$id);
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.invoices, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "invoice",
entityId: id,
changes: {
number: existing.number,
items: items.rows.length,
linkedFinanceEntries: linkedEntries.rows.length,
},
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/invoices");
revalidatePath("/finance");
return { ok: true };
}
// -------------------- Invoice items --------------------
function pickItemFields(formData: FormData) {
return {
description: String(formData.get("description") ?? "").trim(),
quantity: String(formData.get("quantity") ?? "1"),
unitPrice: String(formData.get("unitPrice") ?? "0"),
vatRate: String(formData.get("vatRate") ?? "20"),
};
}
function lineTotal(qty: number, unitPrice: number, vatRate: number): number {
const net = qty * unitPrice;
const vat = net * (vatRate / 100);
return Number((net + vat).toFixed(2));
}
export async function addInvoiceItemAction(
_prev: InvoiceActionState,
formData: FormData,
): Promise<InvoiceActionState> {
const invoiceId = String(formData.get("invoiceId") ?? "");
if (!invoiceId) return { ok: false, error: "Fatura ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = invoiceItemSchema.safeParse(pickItemFields(formData));
if (!parsed.success) {
return { ok: false, error: "Kalem geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const invoice = (await tablesDB.getRow(
DATABASE_ID,
TABLES.invoices,
invoiceId,
)) as unknown as Invoice;
if (invoice.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
const total = lineTotal(parsed.data.quantity, parsed.data.unitPrice, parsed.data.vatRate ?? 0);
const row = await tablesDB.createRow(
DATABASE_ID,
TABLES.invoiceItems,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
invoiceId,
description: parsed.data.description,
quantity: parsed.data.quantity,
unitPrice: parsed.data.unitPrice,
vatRate: parsed.data.vatRate ?? 0,
lineTotal: total,
},
teamRowPermissions(ctx.tenantId),
);
await recomputeTotals(ctx.tenantId, invoiceId, ctx.user.id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "invoice_item",
entityId: row.$id,
changes: { invoiceId, ...parsed.data, lineTotal: total },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath(`/invoices/${invoiceId}`);
return { ok: true, invoiceId };
}
export async function updateInvoiceItemAction(
_prev: InvoiceActionState,
formData: FormData,
): Promise<InvoiceActionState> {
const id = String(formData.get("id") ?? "");
if (!id) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = invoiceItemSchema.safeParse(pickItemFields(formData));
if (!parsed.success) {
return { ok: false, error: "Kalem geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.invoiceItems,
id,
)) as unknown as InvoiceItem;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
const total = lineTotal(parsed.data.quantity, parsed.data.unitPrice, parsed.data.vatRate ?? 0);
await tablesDB.updateRow(DATABASE_ID, TABLES.invoiceItems, id, {
description: parsed.data.description,
quantity: parsed.data.quantity,
unitPrice: parsed.data.unitPrice,
vatRate: parsed.data.vatRate ?? 0,
lineTotal: total,
});
await recomputeTotals(ctx.tenantId, existing.invoiceId, ctx.user.id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "invoice_item",
entityId: id,
changes: { ...parsed.data, lineTotal: total },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath(`/invoices/${(await getItemInvoiceId(id)) ?? ""}`);
return { ok: true };
}
async function getItemInvoiceId(itemId: string): Promise<string | null> {
try {
const { tablesDB } = createAdminClient();
const row = (await tablesDB.getRow(
DATABASE_ID,
TABLES.invoiceItems,
itemId,
)) as unknown as InvoiceItem;
return row.invoiceId;
} catch {
return null;
}
}
export async function deleteInvoiceItemAction(
formData: FormData,
): Promise<InvoiceActionState> {
const id = String(formData.get("id") ?? "");
if (!id) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.invoiceItems,
id,
)) as unknown as InvoiceItem;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.invoiceItems, id);
await recomputeTotals(ctx.tenantId, existing.invoiceId, ctx.user.id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "invoice_item",
entityId: id,
changes: { invoiceId: existing.invoiceId },
});
revalidatePath(`/invoices/${existing.invoiceId}`);
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
return { ok: true };
}