feat: desktop image thumbnails, gallery lightbox portal, client-side compression, clickable table rows, fix header gap
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import "server-only";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { createAdminClient, getCurrentUser } from "./server";
|
||||
import { DATABASE_ID, TABLES, type TenantSettings } from "./schema";
|
||||
import { ACTIVE_TENANT_COOKIE } from "./tenant-types";
|
||||
import { getActiveTenantId, getUserTeams } from "./tenant";
|
||||
|
||||
export type ActiveContext = {
|
||||
@@ -16,16 +18,34 @@ export async function getActiveContext(): Promise<ActiveContext | null> {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return null;
|
||||
|
||||
const { teams: adminTeams, tablesDB } = createAdminClient();
|
||||
|
||||
let tenantId = await getActiveTenantId();
|
||||
if (!tenantId) {
|
||||
const teams = await getUserTeams();
|
||||
tenantId = teams?.teams[0]?.$id ?? null;
|
||||
|
||||
// Validate cookie tenantId — user must actually be a member of that team.
|
||||
if (tenantId) {
|
||||
try {
|
||||
const memberships = await adminTeams.listMemberships(tenantId);
|
||||
const isMember = memberships.memberships.some((m) => m.userId === user.$id);
|
||||
if (!isMember) {
|
||||
// Stale or cross-account cookie — clear and re-resolve.
|
||||
try { (await cookies()).delete(ACTIVE_TENANT_COOKIE); } catch { /* ignore */ }
|
||||
tenantId = null;
|
||||
}
|
||||
} catch {
|
||||
tenantId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!tenantId) {
|
||||
const userTeams = await getUserTeams();
|
||||
tenantId = userTeams?.teams[0]?.$id ?? null;
|
||||
}
|
||||
|
||||
if (!tenantId) return null;
|
||||
|
||||
let settings: TenantSettings | null = null;
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.tenantSettings,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { ID, Permission, Role } from "node-appwrite";
|
||||
import { ID, Permission, Role, Query } from "node-appwrite";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { customerSchema } from "@/lib/validation/customers";
|
||||
import { DATABASE_ID, TABLES } from "./schema";
|
||||
import { DATABASE_ID, TABLES, type CustomerStage } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
|
||||
@@ -35,6 +35,10 @@ export async function createCustomerAction(
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
type: data.type,
|
||||
stage: data.stage ?? "ilk_temas",
|
||||
source: data.source,
|
||||
nextFollowUpDate: data.nextFollowUpDate,
|
||||
assigneeId: data.assigneeId,
|
||||
notes: data.notes,
|
||||
createdBy: ctx.user.id,
|
||||
},
|
||||
@@ -74,6 +78,10 @@ export async function updateCustomerAction(
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
type: data.type,
|
||||
stage: data.stage,
|
||||
source: data.source,
|
||||
nextFollowUpDate: data.nextFollowUpDate,
|
||||
assigneeId: data.assigneeId,
|
||||
notes: data.notes,
|
||||
});
|
||||
} catch {
|
||||
@@ -84,6 +92,23 @@ export async function updateCustomerAction(
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateCustomerStageAction(
|
||||
id: string,
|
||||
stage: CustomerStage,
|
||||
): Promise<ActionState> {
|
||||
await requireTenant();
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
try {
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.customers, id, { stage });
|
||||
} catch {
|
||||
return { ok: false, error: "Aşama güncellenemedi." };
|
||||
}
|
||||
|
||||
revalidatePath("/customers");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteCustomerAction(id: string): Promise<ActionState> {
|
||||
await requireTenant();
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { DATABASE_ID, TABLES, type Activity, type Customer, type Property } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
|
||||
function buildSixMonthKeys() {
|
||||
const months: Record<string, number> = {};
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
d.setDate(1);
|
||||
d.setMonth(d.getMonth() - i);
|
||||
months[d.toLocaleString("tr-TR", { month: "short", year: "2-digit" })] = 0;
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
function buildMonthlyTrend(properties: Property[]) {
|
||||
const months = buildSixMonthKeys();
|
||||
for (const p of properties) {
|
||||
const key = new Date(p.$createdAt).toLocaleString("tr-TR", { month: "short", year: "2-digit" });
|
||||
if (key in months) months[key]++;
|
||||
}
|
||||
return Object.entries(months).map(([ay, ilanSayisi]) => ({ ay, ilanSayisi }));
|
||||
}
|
||||
|
||||
function buildCustomerMonthlyTrend(customers: Customer[]) {
|
||||
const months = buildSixMonthKeys();
|
||||
for (const c of customers) {
|
||||
const key = new Date(c.$createdAt).toLocaleString("tr-TR", { month: "short", year: "2-digit" });
|
||||
if (key in months) months[key]++;
|
||||
}
|
||||
return Object.entries(months).map(([ay, musteriSayisi]) => ({ ay, musteriSayisi }));
|
||||
}
|
||||
|
||||
function buildActivityMonthlyTrend(activities: Activity[]) {
|
||||
const months = buildSixMonthKeys();
|
||||
for (const a of activities) {
|
||||
const key = new Date(a.$createdAt).toLocaleString("tr-TR", { month: "short", year: "2-digit" });
|
||||
if (key in months) months[key]++;
|
||||
}
|
||||
return Object.entries(months).map(([ay, aktiviteSayisi]) => ({ ay, aktiviteSayisi }));
|
||||
}
|
||||
|
||||
const PROPERTY_TYPE_LABELS: Record<string, string> = {
|
||||
daire: "Daire",
|
||||
villa: "Villa",
|
||||
arsa: "Arsa",
|
||||
dukkan: "Dükkan",
|
||||
ofis: "Ofis",
|
||||
depo: "Depo",
|
||||
};
|
||||
|
||||
function buildPropertyDistribution(properties: Property[]) {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const p of properties) {
|
||||
counts[p.propertyType] = (counts[p.propertyType] ?? 0) + 1;
|
||||
}
|
||||
return Object.entries(counts)
|
||||
.filter(([, n]) => n > 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([tip, sayi]) => ({ tip, label: PROPERTY_TYPE_LABELS[tip] ?? tip, sayi }));
|
||||
}
|
||||
|
||||
export type DashboardStats = {
|
||||
aktifIlanlar: number;
|
||||
satilikAktif: number;
|
||||
kiralikAktif: number;
|
||||
toplamMusteri: number;
|
||||
aliciMusteri: number;
|
||||
kiraciMusteri: number;
|
||||
yatirimciMusteri: number;
|
||||
rezerveIlanlar: number;
|
||||
bekleyenEslesmeler: number;
|
||||
buAyIlanlar: number;
|
||||
sonAktiviteler: Activity[];
|
||||
sonIlanlar: Property[];
|
||||
aylikTrend: { ay: string; ilanSayisi: number }[];
|
||||
aylikMusteriTrend: { ay: string; musteriSayisi: number }[];
|
||||
aylikAktiviteTrend: { ay: string; aktiviteSayisi: number }[];
|
||||
portfoyDagilim: { tip: string; label: string; sayi: number }[];
|
||||
takipMusteri: Customer[];
|
||||
};
|
||||
|
||||
export async function getDashboardStats(tenantId: string): Promise<DashboardStats> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(23, 59, 59, 999);
|
||||
|
||||
const thisMonthStart = new Date();
|
||||
thisMonthStart.setDate(1);
|
||||
thisMonthStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const sixMonthsAgo = new Date();
|
||||
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
|
||||
sixMonthsAgo.setDate(1);
|
||||
sixMonthsAgo.setHours(0, 0, 0, 0);
|
||||
|
||||
const base = (extra: string[]) => [Query.equal("tenantId", tenantId), ...extra, Query.limit(1)];
|
||||
const trend = (table: string) => [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.greaterThanEqual("$createdAt", sixMonthsAgo.toISOString()),
|
||||
Query.orderAsc("$createdAt"),
|
||||
Query.limit(500),
|
||||
];
|
||||
|
||||
const [
|
||||
aktifRes, satilikRes, kiralikRes,
|
||||
musteriRes, aliciRes, kiraciRes, yatirimciRes,
|
||||
eslesmelerRes, buAyRes,
|
||||
aktivitelerRes, ilanlarRes,
|
||||
ilanTrendRes, musteriTrendRes, aktiviteTrendRes,
|
||||
aktifPropAllRes,
|
||||
takipRes, rezerveRes,
|
||||
] = await Promise.all([
|
||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: base([Query.equal("status", "aktif")]) }),
|
||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: base([Query.equal("status", "aktif"), Query.equal("listingType", "satilik")]) }),
|
||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: base([Query.equal("status", "aktif"), Query.equal("listingType", "kiralik")]) }),
|
||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.customers, queries: base([]) }),
|
||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.customers, queries: base([Query.equal("type", "alici")]) }),
|
||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.customers, queries: base([Query.equal("type", "kiraci")]) }),
|
||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.customers, queries: base([Query.equal("type", "yatirimci")]) }),
|
||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.propertyMatches, queries: base([Query.equal("notified", false)]) }),
|
||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: base([Query.greaterThanEqual("$createdAt", thisMonthStart.toISOString())]) }),
|
||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.activities, queries: [Query.equal("tenantId", tenantId), Query.orderDesc("$createdAt"), Query.limit(6)] }),
|
||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: [Query.equal("tenantId", tenantId), Query.orderDesc("$createdAt"), Query.limit(5)] }),
|
||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: trend(TABLES.properties) }),
|
||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.customers, queries: trend(TABLES.customers) }),
|
||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.activities, queries: trend(TABLES.activities) }),
|
||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: [Query.equal("tenantId", tenantId), Query.equal("status", "aktif"), Query.limit(500)] }),
|
||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.customers, queries: [Query.equal("tenantId", tenantId), Query.lessThanEqual("nextFollowUpDate", today.toISOString()), Query.orderAsc("nextFollowUpDate"), Query.limit(10)] }),
|
||||
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: base([Query.equal("status", "rezerve")]) }),
|
||||
]);
|
||||
|
||||
const parse = <T>(res: { rows: unknown[] }) => JSON.parse(JSON.stringify(res.rows)) as T[];
|
||||
|
||||
return {
|
||||
aktifIlanlar: aktifRes.total,
|
||||
satilikAktif: satilikRes.total,
|
||||
kiralikAktif: kiralikRes.total,
|
||||
toplamMusteri: musteriRes.total,
|
||||
aliciMusteri: aliciRes.total,
|
||||
kiraciMusteri: kiraciRes.total,
|
||||
yatirimciMusteri: yatirimciRes.total,
|
||||
bekleyenEslesmeler: eslesmelerRes.total,
|
||||
buAyIlanlar: buAyRes.total,
|
||||
rezerveIlanlar: rezerveRes.total,
|
||||
sonAktiviteler: parse<Activity>(aktivitelerRes),
|
||||
sonIlanlar: parse<Property>(ilanlarRes),
|
||||
aylikTrend: buildMonthlyTrend(parse<Property>(ilanTrendRes)),
|
||||
aylikMusteriTrend: buildCustomerMonthlyTrend(parse<Customer>(musteriTrendRes)),
|
||||
aylikAktiviteTrend: buildActivityMonthlyTrend(parse<Activity>(aktiviteTrendRes)),
|
||||
portfoyDagilim: buildPropertyDistribution(parse<Property>(aktifPropAllRes)),
|
||||
takipMusteri: parse<Customer>(takipRes),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use server";
|
||||
|
||||
import { ID, Permission, Role, Query } from "node-appwrite";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { dealSchema } from "@/lib/validation/deals";
|
||||
import { DATABASE_ID, TABLES, type DealStatus, type Customer } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant, requireRole } from "@/lib/appwrite/tenant-guard";
|
||||
|
||||
async function resolveCustomerName(tablesDB: ReturnType<typeof createAdminClient>["tablesDB"], customerId?: string): Promise<string | undefined> {
|
||||
if (!customerId) return undefined;
|
||||
try {
|
||||
const row = await tablesDB.getRow(DATABASE_ID, TABLES.customers, customerId);
|
||||
return (row as unknown as Customer).name;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> };
|
||||
|
||||
export async function createDealAction(
|
||||
_prev: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const ctx = await requireTenant();
|
||||
const raw = Object.fromEntries(formData.entries());
|
||||
const parsed = dealSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors };
|
||||
}
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
const data = parsed.data;
|
||||
const customerName = await resolveCustomerName(tablesDB, data.customerId);
|
||||
|
||||
try {
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.deals,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
propertyId: data.propertyId,
|
||||
customerId: data.customerId,
|
||||
propertyTitle: data.propertyTitle,
|
||||
customerName,
|
||||
type: data.type,
|
||||
status: "bekleyen",
|
||||
salePrice: data.salePrice,
|
||||
commissionRate: data.commissionRate,
|
||||
commissionAmount: data.commissionAmount,
|
||||
officeSharePercent: data.officeSharePercent,
|
||||
agentSharePercent: data.agentSharePercent,
|
||||
referralName: data.referralName || null,
|
||||
referralPhone: data.referralPhone || null,
|
||||
referralPercent: data.referralPercent ?? null,
|
||||
agentId: ctx.user.id,
|
||||
agentName: ctx.user.name,
|
||||
closingDate: data.closingDate,
|
||||
notes: data.notes,
|
||||
createdBy: ctx.user.id,
|
||||
},
|
||||
[
|
||||
Permission.read(Role.team(ctx.tenantId)),
|
||||
Permission.update(Role.team(ctx.tenantId)),
|
||||
Permission.delete(Role.team(ctx.tenantId, "owner")),
|
||||
Permission.delete(Role.team(ctx.tenantId, "admin")),
|
||||
],
|
||||
);
|
||||
} catch {
|
||||
return { ok: false, error: "İşlem kaydedilemedi." };
|
||||
}
|
||||
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateDealAction(
|
||||
id: string,
|
||||
_prev: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const ctx = await requireTenant();
|
||||
const raw = Object.fromEntries(formData.entries());
|
||||
const parsed = dealSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors };
|
||||
}
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
const data = parsed.data;
|
||||
const customerName = await resolveCustomerName(tablesDB, data.customerId);
|
||||
|
||||
// Agents can only edit their own deals; owners/admins can edit all
|
||||
if (ctx.role === "member") {
|
||||
const existing = await tablesDB.getRow(DATABASE_ID, TABLES.deals, id);
|
||||
if ((existing as unknown as { agentId: string }).agentId !== ctx.user.id) {
|
||||
return { ok: false, error: "Bu işlemi düzenleme yetkiniz yok." };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.deals, id, {
|
||||
propertyId: data.propertyId,
|
||||
customerId: data.customerId,
|
||||
propertyTitle: data.propertyTitle,
|
||||
customerName,
|
||||
type: data.type,
|
||||
salePrice: data.salePrice,
|
||||
commissionRate: data.commissionRate,
|
||||
commissionAmount: data.commissionAmount,
|
||||
officeSharePercent: data.officeSharePercent,
|
||||
agentSharePercent: data.agentSharePercent,
|
||||
referralName: data.referralName || null,
|
||||
referralPhone: data.referralPhone || null,
|
||||
referralPercent: data.referralPercent ?? null,
|
||||
closingDate: data.closingDate,
|
||||
notes: data.notes,
|
||||
});
|
||||
} catch {
|
||||
return { ok: false, error: "İşlem güncellenemedi." };
|
||||
}
|
||||
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateDealStatusAction(
|
||||
id: string,
|
||||
status: DealStatus,
|
||||
): Promise<ActionState> {
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin"]);
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
try {
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.deals, id, { status });
|
||||
} catch {
|
||||
return { ok: false, error: "Durum güncellenemedi." };
|
||||
}
|
||||
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteDealAction(id: string): Promise<ActionState> {
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin"]);
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
try {
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.deals, id);
|
||||
} catch {
|
||||
return { ok: false, error: "İşlem silinemedi." };
|
||||
}
|
||||
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function getDealsAction() {
|
||||
const ctx = await requireTenant();
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
const queries = [
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(200),
|
||||
];
|
||||
|
||||
// Members only see their own deals
|
||||
if (ctx.role === "member") {
|
||||
queries.push(Query.equal("agentId", ctx.user.id));
|
||||
}
|
||||
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.deals,
|
||||
queries,
|
||||
});
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { DATABASE_ID, TABLES } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
|
||||
type ActionState = { ok: boolean; error?: string };
|
||||
|
||||
export async function markMatchNotifiedAction(id: string): Promise<ActionState> {
|
||||
await requireTenant();
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
try {
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.propertyMatches, id, {
|
||||
notified: true,
|
||||
});
|
||||
} catch {
|
||||
return { ok: false, error: "Güncellenemedi." };
|
||||
}
|
||||
|
||||
revalidatePath("/customers/matches");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function markAllNotifiedAction(ids: string[]): Promise<ActionState> {
|
||||
await requireTenant();
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
ids.map((id) =>
|
||||
tablesDB.updateRow(DATABASE_ID, TABLES.propertyMatches, id, { notified: true }),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
return { ok: false, error: "Toplu güncelleme başarısız." };
|
||||
}
|
||||
|
||||
revalidatePath("/customers/matches");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { TenantPlan } from "./schema";
|
||||
|
||||
export type PlanResource = "properties" | "customers" | "members" | "presentations";
|
||||
|
||||
export const PLAN_LIMIT_EXCEEDED = "PLAN_LIMIT_EXCEEDED";
|
||||
|
||||
const INF = Number.POSITIVE_INFINITY;
|
||||
|
||||
export const PLAN_LIMITS: Record<TenantPlan, Record<PlanResource, number>> = {
|
||||
free: {
|
||||
properties: 5,
|
||||
customers: 10,
|
||||
members: 2,
|
||||
presentations: 3,
|
||||
},
|
||||
pro: {
|
||||
properties: INF,
|
||||
customers: INF,
|
||||
members: INF,
|
||||
presentations: INF,
|
||||
},
|
||||
};
|
||||
|
||||
export const RESOURCE_LABELS: Record<PlanResource, string> = {
|
||||
properties: "ilan",
|
||||
customers: "müşteri",
|
||||
members: "ekip üyesi",
|
||||
presentations: "sunum",
|
||||
};
|
||||
@@ -3,37 +3,20 @@ import "server-only";
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { createAdminClient } from "./server";
|
||||
import { DATABASE_ID, TABLES, type TenantPlan } from "./schema";
|
||||
import { DATABASE_ID, TABLES } from "./schema";
|
||||
import type { TenantContext } from "./tenant-guard";
|
||||
import type { TenantPlan } from "./schema";
|
||||
import {
|
||||
PLAN_LIMITS,
|
||||
RESOURCE_LABELS,
|
||||
type PlanResource,
|
||||
} from "./plan-limits-shared";
|
||||
|
||||
export type PlanResource = "properties" | "customers" | "members" | "presentations";
|
||||
|
||||
export const PLAN_LIMIT_EXCEEDED = "PLAN_LIMIT_EXCEEDED";
|
||||
export type { PlanResource } from "./plan-limits-shared";
|
||||
export { PLAN_LIMIT_EXCEEDED, PLAN_LIMITS, RESOURCE_LABELS } from "./plan-limits-shared";
|
||||
|
||||
const INF = Number.POSITIVE_INFINITY;
|
||||
|
||||
export const PLAN_LIMITS: Record<TenantPlan, Record<PlanResource, number>> = {
|
||||
free: {
|
||||
properties: 5,
|
||||
customers: 10,
|
||||
members: 2,
|
||||
presentations: 3,
|
||||
},
|
||||
pro: {
|
||||
properties: INF,
|
||||
customers: INF,
|
||||
members: INF,
|
||||
presentations: INF,
|
||||
},
|
||||
};
|
||||
|
||||
export const RESOURCE_LABELS: Record<PlanResource, string> = {
|
||||
properties: "ilan",
|
||||
customers: "müşteri",
|
||||
members: "ekip üyesi",
|
||||
presentations: "sunum",
|
||||
};
|
||||
|
||||
export function getEffectivePlan(ctx: TenantContext): TenantPlan {
|
||||
const plan = (ctx.settings?.plan as TenantPlan | undefined) ?? "free";
|
||||
if (plan === "pro") {
|
||||
@@ -84,7 +67,7 @@ export async function getPlanUsage(ctx: TenantContext): Promise<PlanUsage> {
|
||||
}
|
||||
|
||||
export class PlanLimitError extends Error {
|
||||
code = PLAN_LIMIT_EXCEEDED;
|
||||
code = "PLAN_LIMIT_EXCEEDED";
|
||||
constructor(public resource: PlanResource, public limit: number) {
|
||||
super(`Plan limit reached for ${resource} (${limit})`);
|
||||
}
|
||||
|
||||
@@ -128,6 +128,25 @@ export async function updatePropertyAction(
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updatePropertyImagesAction(
|
||||
propertyId: string,
|
||||
imageIds: string[],
|
||||
): Promise<ActionState> {
|
||||
await requireTenant();
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
try {
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.properties, propertyId, {
|
||||
imageIds: JSON.stringify(imageIds),
|
||||
});
|
||||
} catch {
|
||||
return { ok: false, error: "Fotoğraflar kaydedilemedi." };
|
||||
}
|
||||
|
||||
revalidatePath(`/properties/${propertyId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deletePropertyAction(id: string): Promise<ActionState> {
|
||||
await requireTenant();
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
@@ -15,6 +15,7 @@ export const TABLES = {
|
||||
activities: "activities",
|
||||
tenantSettings: "tenant_settings",
|
||||
inviteLinks: "invite_links",
|
||||
deals: "deals",
|
||||
} as const;
|
||||
|
||||
export type TableId = (typeof TABLES)[keyof typeof TABLES];
|
||||
@@ -49,9 +50,13 @@ export interface InviteLink extends SystemRow {
|
||||
|
||||
export type PropertyType = "daire" | "villa" | "arsa" | "dukkan" | "ofis" | "depo";
|
||||
export type ListingType = "satilik" | "kiralik";
|
||||
export type PropertyStatus = "aktif" | "pasif" | "satildi" | "kiralandit";
|
||||
export type PropertyStatus = "aktif" | "rezerve" | "pasif" | "satildi" | "kiralandit";
|
||||
export type CustomerType = "alici" | "kiraci" | "yatirimci";
|
||||
export type CustomerStage = "ilk_temas" | "aktif_arama" | "teklif" | "sozlesme" | "kapandi";
|
||||
export type CustomerSource = "referans" | "ilan" | "instagram" | "sahibinden" | "diger";
|
||||
export type ActivityType = "gorusme" | "teklif" | "ziyaret" | "arama" | "not";
|
||||
export type DealType = "satis" | "kiralama";
|
||||
export type DealStatus = "bekleyen" | "tahsil_edildi" | "iptal";
|
||||
|
||||
export interface Property extends Row {
|
||||
tenantId: string;
|
||||
@@ -86,6 +91,10 @@ export interface Customer extends Row {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
type: CustomerType;
|
||||
stage?: CustomerStage;
|
||||
source?: CustomerSource;
|
||||
nextFollowUpDate?: string;
|
||||
assigneeId?: string;
|
||||
notes?: string;
|
||||
createdBy: string;
|
||||
}
|
||||
@@ -149,6 +158,29 @@ export interface Investor extends Row {
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface Deal extends Row {
|
||||
tenantId: string;
|
||||
propertyId?: string;
|
||||
customerId?: string;
|
||||
propertyTitle?: string;
|
||||
customerName?: string;
|
||||
type: DealType;
|
||||
status?: DealStatus;
|
||||
salePrice: number;
|
||||
commissionRate: number;
|
||||
commissionAmount: number;
|
||||
officeSharePercent?: number;
|
||||
agentSharePercent?: number;
|
||||
referralName?: string;
|
||||
referralPhone?: string;
|
||||
referralPercent?: number;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
closingDate?: string;
|
||||
notes?: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface Activity extends Row {
|
||||
tenantId: string;
|
||||
customerId?: string;
|
||||
@@ -218,17 +250,45 @@ export const LISTING_TYPE_LABELS: Record<ListingType, string> = {
|
||||
|
||||
export const PROPERTY_STATUS_LABELS: Record<PropertyStatus, string> = {
|
||||
aktif: "Aktif",
|
||||
rezerve: "Rezerve",
|
||||
pasif: "Pasif",
|
||||
satildi: "Satıldı",
|
||||
kiralandit: "Kiralandı",
|
||||
};
|
||||
|
||||
export const CUSTOMER_STAGE_LABELS: Record<CustomerStage, string> = {
|
||||
ilk_temas: "İlk Temas",
|
||||
aktif_arama: "Aktif Arama",
|
||||
teklif: "Teklif",
|
||||
sozlesme: "Sözleşme",
|
||||
kapandi: "Kapandı",
|
||||
};
|
||||
|
||||
export const CUSTOMER_SOURCE_LABELS: Record<CustomerSource, string> = {
|
||||
referans: "Referans",
|
||||
ilan: "İlan",
|
||||
instagram: "InstagramLogo",
|
||||
sahibinden: "Sahibinden",
|
||||
diger: "Diğer",
|
||||
};
|
||||
|
||||
export const CUSTOMER_TYPE_LABELS: Record<CustomerType, string> = {
|
||||
alici: "Alıcı",
|
||||
kiraci: "Kiracı",
|
||||
yatirimci: "Yatırımcı",
|
||||
};
|
||||
|
||||
export const DEAL_TYPE_LABELS: Record<DealType, string> = {
|
||||
satis: "Satış",
|
||||
kiralama: "Kiralama",
|
||||
};
|
||||
|
||||
export const DEAL_STATUS_LABELS: Record<DealStatus, string> = {
|
||||
bekleyen: "Bekleyen",
|
||||
tahsil_edildi: "Tahsil Edildi",
|
||||
iptal: "İptal",
|
||||
};
|
||||
|
||||
export const ACTIVITY_TYPE_LABELS: Record<ActivityType, string> = {
|
||||
gorusme: "Görüşme",
|
||||
teklif: "Teklif",
|
||||
|
||||
@@ -1,45 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { ID, Permission, Role } from "node-appwrite";
|
||||
import { InputFile } from "node-appwrite/file";
|
||||
|
||||
import { BUCKETS } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
|
||||
type UploadResult = { ok: boolean; fileId?: string; error?: string };
|
||||
type DeleteResult = { ok: boolean; error?: string };
|
||||
|
||||
export async function uploadPropertyImageAction(formData: FormData): Promise<UploadResult> {
|
||||
const ctx = await requireTenant();
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) return { ok: false, error: "Dosya seçilmedi." };
|
||||
|
||||
const { storage } = createAdminClient();
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const inputFile = InputFile.fromBuffer(buffer, file.name);
|
||||
|
||||
const result = await storage.createFile(
|
||||
BUCKETS.propertyImages,
|
||||
ID.unique(),
|
||||
inputFile,
|
||||
[
|
||||
Permission.read(Role.any()),
|
||||
Permission.update(Role.team(ctx.tenantId)),
|
||||
Permission.delete(Role.team(ctx.tenantId, "owner")),
|
||||
Permission.delete(Role.team(ctx.tenantId, "admin")),
|
||||
],
|
||||
);
|
||||
|
||||
return { ok: true, fileId: result.$id };
|
||||
} catch (e) {
|
||||
console.error("Image upload error:", e);
|
||||
return { ok: false, error: "Fotoğraf yüklenemedi." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePropertyImageAction(fileId: string): Promise<DeleteResult> {
|
||||
await requireTenant();
|
||||
const { storage } = createAdminClient();
|
||||
|
||||
@@ -24,34 +24,61 @@ function pickHighestRole(roles: string[]): TenantRole | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveFirstValidTenantId(userId: string): Promise<string | null> {
|
||||
try {
|
||||
const { users, tablesDB } = createAdminClient();
|
||||
const memberships = await users.listMemberships(userId);
|
||||
if (memberships.total === 0) return null;
|
||||
const teamIds = memberships.memberships.map((m) => m.teamId);
|
||||
const settings = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.tenantSettings,
|
||||
queries: [Query.equal("tenantId", teamIds), Query.limit(1)],
|
||||
});
|
||||
return (settings.rows[0] as unknown as { tenantId: string })?.tenantId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function setTenantCookie(tenantId: string) {
|
||||
try {
|
||||
(await cookies()).set(ACTIVE_TENANT_COOKIE, tenantId, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export async function requireTenant(): Promise<TenantContext> {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) throw new Error("UNAUTHENTICATED");
|
||||
|
||||
let tenantId = await getActiveTenantId();
|
||||
if (!tenantId) {
|
||||
// Fallback: pick the user's first team (handles invite acceptees and
|
||||
// sessions where the active-tenant cookie/prefs weren't set yet).
|
||||
const userTeams = await getUserTeams();
|
||||
tenantId = userTeams?.teams[0]?.$id ?? null;
|
||||
if (tenantId) {
|
||||
try {
|
||||
(await cookies()).set(ACTIVE_TENANT_COOKIE, tenantId, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
});
|
||||
} catch {
|
||||
/* setting cookie can fail in some Server Component paths; ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!tenantId) throw new Error("NO_TENANT");
|
||||
|
||||
const { tablesDB, teams } = createAdminClient();
|
||||
|
||||
// If we have a tenantId from cookie/prefs, verify the team still exists.
|
||||
if (tenantId) {
|
||||
try {
|
||||
await teams.get(tenantId);
|
||||
} catch {
|
||||
// Team was deleted — clear stale pointer and fall through to resolution.
|
||||
try { (await cookies()).delete(ACTIVE_TENANT_COOKIE); } catch { /* ignore */ }
|
||||
tenantId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!tenantId) {
|
||||
tenantId = await resolveFirstValidTenantId(user.$id);
|
||||
if (tenantId) await setTenantCookie(tenantId);
|
||||
}
|
||||
|
||||
if (!tenantId) throw new Error("NO_TENANT");
|
||||
|
||||
const memberships = await teams.listMemberships(tenantId);
|
||||
const membership = memberships.memberships.find((m) => m.userId === user.$id);
|
||||
if (!membership) throw new Error("NOT_A_MEMBER");
|
||||
|
||||
Reference in New Issue
Block a user