feat(software): catalog + customer assignments (M2M)

Software catalog with per-customer assignments via the customer_software
join table. Two tabs in one /software page:

Catalog tab:
- Software CRUD: name, version, description, defaultFee (TRY).
- Deleting a software cascades and removes all its assignments first
  (best-effort loop, then the catalog row), all wrapped in audit logs.

Assignments tab:
- M2M between customer and software with own fee (overrides defaultFee),
  billingPeriod (monthly default), startDate/endDate, notes.
- Form auto-fills fee from selected software's defaultFee.
- Both Sheet forms localized; date inputs round-tripped via toIsoDate
  (Appwrite expects ISO 8601 with TZ; HTML date input gives YYYY-MM-DD).
- Delete dialogs differentiated for catalog ('siliniyor') vs assignment
  ('kaldırılıyor').

New files:
- lib/validation/software.ts (softwareSchema + customerSoftwareSchema)
- lib/appwrite/software-actions.ts (6 server actions)
- lib/appwrite/software-queries.ts (listSoftware, listAssignments)
- lib/appwrite/software-types.ts (form state)
- /software route with SoftwareClient (Tabs), SoftwareFormSheet,
  AssignmentFormSheet, inline delete dialogs.

Empty states surface the right next-step CTA: 'önce müşteri ekleyin', or
'önce yazılım ekleyin', as appropriate.
This commit is contained in:
kovakmedya
2026-04-30 05:50:33 +03:00
parent a15a1c1c1a
commit 113988273f
9 changed files with 1483 additions and 0 deletions
+370
View File
@@ -0,0 +1,370 @@
"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 CustomerSoftware,
type Software,
} from "./schema";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
import type { SoftwareActionState } from "./software-types";
import { customerSoftwareSchema, softwareSchema } from "@/lib/validation/software";
function appwriteError(e: unknown): string {
if (e instanceof AppwriteException) {
return e.message || "Beklenmeyen bir hata oluştu.";
}
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")),
];
}
// -------------------- Software (catalog) --------------------
function pickSoftwareFields(formData: FormData) {
return {
name: String(formData.get("name") ?? "").trim(),
version: String(formData.get("version") ?? "").trim(),
description: String(formData.get("description") ?? "").trim(),
defaultFee: String(formData.get("defaultFee") ?? ""),
};
}
export async function createSoftwareAction(
_prev: SoftwareActionState,
formData: FormData,
): Promise<SoftwareActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = softwareSchema.safeParse(pickSoftwareFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const row = await tablesDB.createRow(
DATABASE_ID,
TABLES.software,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
...parsed.data,
},
teamRowPermissions(ctx.tenantId),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "software",
entityId: row.$id,
changes: parsed.data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/software");
return { ok: true };
}
export async function updateSoftwareAction(
_prev: SoftwareActionState,
formData: FormData,
): Promise<SoftwareActionState> {
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 = softwareSchema.safeParse(pickSoftwareFields(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.software,
id,
)) as unknown as Software;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
await tablesDB.updateRow(DATABASE_ID, TABLES.software, id, parsed.data);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "software",
entityId: id,
changes: parsed.data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/software");
return { ok: true };
}
export async function deleteSoftwareAction(formData: FormData): Promise<SoftwareActionState> {
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.software,
id,
)) as unknown as Software;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
// Detach from all customer_software rows first
const assignments = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.customerSoftware,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.equal("softwareId", id),
Query.limit(500),
],
});
for (const row of assignments.rows) {
await tablesDB.deleteRow(DATABASE_ID, TABLES.customerSoftware, row.$id);
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.software, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "software",
entityId: id,
changes: { name: existing.name, detachedAssignments: assignments.rows.length },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/software");
return { ok: true };
}
// -------------------- customer_software (assignments) --------------------
function pickAssignmentFields(formData: FormData) {
return {
customerId: String(formData.get("customerId") ?? ""),
softwareId: String(formData.get("softwareId") ?? ""),
startDate: String(formData.get("startDate") ?? ""),
endDate: String(formData.get("endDate") ?? ""),
fee: String(formData.get("fee") ?? ""),
billingPeriod: (formData.get("billingPeriod") as "monthly" | "yearly" | "onetime" | null) ??
"monthly",
notes: String(formData.get("notes") ?? "").trim(),
};
}
function toIsoDate(v?: string): string | undefined {
if (!v) return undefined;
// input type=date sends YYYY-MM-DD; Appwrite expects ISO with timezone
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return `${v}T00:00:00.000+00:00`;
return v;
}
export async function createAssignmentAction(
_prev: SoftwareActionState,
formData: FormData,
): Promise<SoftwareActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = customerSoftwareSchema.safeParse(pickAssignmentFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const data = {
...parsed.data,
startDate: toIsoDate(parsed.data.startDate),
endDate: toIsoDate(parsed.data.endDate),
};
const row = await tablesDB.createRow(
DATABASE_ID,
TABLES.customerSoftware,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
...data,
},
teamRowPermissions(ctx.tenantId),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "customer_software",
entityId: row.$id,
changes: data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/software");
return { ok: true };
}
export async function updateAssignmentAction(
_prev: SoftwareActionState,
formData: FormData,
): Promise<SoftwareActionState> {
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 = customerSoftwareSchema.safeParse(pickAssignmentFields(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.customerSoftware,
id,
)) as unknown as CustomerSoftware;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
const data = {
...parsed.data,
startDate: toIsoDate(parsed.data.startDate),
endDate: toIsoDate(parsed.data.endDate),
};
await tablesDB.updateRow(DATABASE_ID, TABLES.customerSoftware, id, data);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "customer_software",
entityId: id,
changes: data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/software");
return { ok: true };
}
export async function deleteAssignmentAction(formData: FormData): Promise<SoftwareActionState> {
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.customerSoftware,
id,
)) as unknown as CustomerSoftware;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.customerSoftware, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "customer_software",
entityId: id,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/software");
return { ok: true };
}