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
+41
View File
@@ -0,0 +1,41 @@
import { z } from "zod";
export const softwareSchema = z.object({
name: z.string().trim().min(1, "Yazılım adı zorunlu.").max(255),
version: z.string().trim().max(50).optional().transform((v) => (v ? v : undefined)),
description: z
.string()
.trim()
.max(2000)
.optional()
.transform((v) => (v ? v : undefined)),
defaultFee: z
.union([z.number(), z.string()])
.optional()
.transform((v) => {
if (v === undefined || v === "") return undefined;
const n = typeof v === "string" ? Number(v.replace(",", ".")) : v;
return Number.isFinite(n) ? n : undefined;
}),
});
export type SoftwareInput = z.infer<typeof softwareSchema>;
export const customerSoftwareSchema = z.object({
customerId: z.string().min(1, "Müşteri seçin."),
softwareId: z.string().min(1, "Yazılım seçin."),
startDate: z.string().optional().transform((v) => (v ? v : undefined)),
endDate: z.string().optional().transform((v) => (v ? v : undefined)),
fee: z
.union([z.number(), z.string()])
.optional()
.transform((v) => {
if (v === undefined || v === "") return undefined;
const n = typeof v === "string" ? Number(v.replace(",", ".")) : v;
return Number.isFinite(n) ? n : undefined;
}),
billingPeriod: z.enum(["monthly", "yearly", "onetime"]).optional().default("monthly"),
notes: z.string().trim().max(1000).optional().transform((v) => (v ? v : undefined)),
});
export type CustomerSoftwareInput = z.infer<typeof customerSoftwareSchema>;