feat(modules): connections, products, jobs (list/form/detail-placeholder)
Connections (clinic ↔ lab)
- request via member number, approve/reject (counterparty), cancel pending,
delete approved
- permission rows opened to both teams; audit log for every mutation
- /connections page: own code card, request form, pending inbound/outbound
tables, approved connections table with delete confirm
Products (lab catalog)
- createProstheticAction + update + archive/restore + delete (lab-only)
- zod validation, dev-mode error surfacing
- /products page: catalog table + add form + edit dialog. Hidden from
clinic accounts via requireTenantKind.
Jobs (work orders)
- createJobAction (clinic-only) — checks approved connection before write,
permissions opened to both clinic and lab teams
- listInboundJobs (lab perspective), listOutboundJobs (clinic perspective),
listApprovedLabsForClinic for the new-job form
- /jobs/inbound + /jobs/outbound tables with role-aware copy
- /jobs/new full form (lab select, patient code, prosthetic type, member
count, color, due date, price/currency, description)
- /jobs/[jobId] placeholder detail page with stepper visualisation;
status/step updates and file upload come next session
All new mutations follow the memory rules: schema-checked row payloads,
admin client behind requireTenant + requireRole/requireTenantKind, audit
log calls best-effort, no empty-string Radix Select values.
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type Connection,
|
||||
type TenantSettings,
|
||||
} from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireRole, requireTenant } from "./tenant-guard";
|
||||
import type {
|
||||
ConnectionActionState,
|
||||
ConnectionRequestState,
|
||||
} from "./connection-types";
|
||||
|
||||
function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string {
|
||||
if (e instanceof AppwriteException) return e.message || fallback;
|
||||
return process.env.NODE_ENV !== "production" && e instanceof Error
|
||||
? `${fallback} (${e.message})`
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function connectionPermissions(clinicTenantId: string, labTenantId: string): string[] {
|
||||
return [
|
||||
Permission.read(Role.team(clinicTenantId)),
|
||||
Permission.read(Role.team(labTenantId)),
|
||||
Permission.update(Role.team(clinicTenantId, "owner")),
|
||||
Permission.update(Role.team(clinicTenantId, "admin")),
|
||||
Permission.update(Role.team(labTenantId, "owner")),
|
||||
Permission.update(Role.team(labTenantId, "admin")),
|
||||
Permission.delete(Role.team(clinicTenantId, "owner")),
|
||||
Permission.delete(Role.team(labTenantId, "owner")),
|
||||
];
|
||||
}
|
||||
|
||||
export async function requestConnectionAction(
|
||||
_prev: ConnectionRequestState,
|
||||
formData: FormData,
|
||||
): Promise<ConnectionRequestState> {
|
||||
const rawCode = String(formData.get("memberNumber") ?? "").trim().toUpperCase();
|
||||
if (!rawCode) {
|
||||
return { ok: false, error: "Bağlantı kodu zorunlu." };
|
||||
}
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Oturum bulunamadı." };
|
||||
}
|
||||
if (!ctx.kind) {
|
||||
return { ok: false, error: "Hesap türünüz tanımlı değil. Ayarlar > Çalışma alanı." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
// Resolve counterpart by memberNumber
|
||||
const counterpartRes = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.tenantSettings,
|
||||
queries: [Query.equal("memberNumber", rawCode), Query.limit(1)],
|
||||
});
|
||||
const counterpart = counterpartRes.rows[0] as unknown as TenantSettings | undefined;
|
||||
if (!counterpart) {
|
||||
return { ok: false, error: "Bu kod ile eşleşen bir hesap bulunamadı." };
|
||||
}
|
||||
if (counterpart.tenantId === ctx.tenantId) {
|
||||
return { ok: false, error: "Kendi kodunuzla bağlantı kuramazsınız." };
|
||||
}
|
||||
if (counterpart.kind === ctx.kind) {
|
||||
const same = ctx.kind === "lab" ? "laboratuvar" : "klinik";
|
||||
return {
|
||||
ok: false,
|
||||
error: `Bu kod bir ${same} hesabına ait. Bağlantı sadece klinik ↔ laboratuvar arasında olur.`,
|
||||
};
|
||||
}
|
||||
|
||||
const clinicTenantId = ctx.kind === "clinic" ? ctx.tenantId : counterpart.tenantId;
|
||||
const labTenantId = ctx.kind === "lab" ? ctx.tenantId : counterpart.tenantId;
|
||||
|
||||
// Already connected or pending?
|
||||
const existingRes = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.connections,
|
||||
queries: [
|
||||
Query.equal("clinicTenantId", clinicTenantId),
|
||||
Query.equal("labTenantId", labTenantId),
|
||||
Query.limit(1),
|
||||
],
|
||||
});
|
||||
const existing = existingRes.rows[0] as unknown as Connection | undefined;
|
||||
if (existing) {
|
||||
if (existing.status === "approved") {
|
||||
return { ok: false, error: "Bu hesapla zaten bağlantınız var." };
|
||||
}
|
||||
if (existing.status === "pending") {
|
||||
return { ok: false, error: "Bekleyen bir talep zaten var." };
|
||||
}
|
||||
// status === 'rejected' → re-open: reset to pending, requesterBy = current user
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.connections, existing.$id, {
|
||||
status: "pending",
|
||||
requestedBy: ctx.user.id,
|
||||
requestedAt: new Date().toISOString(),
|
||||
approvedAt: null,
|
||||
rejectedAt: null,
|
||||
});
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "connection",
|
||||
entityId: existing.$id,
|
||||
changes: { status: "pending", reopened: true },
|
||||
});
|
||||
} else {
|
||||
const created = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.connections,
|
||||
ID.unique(),
|
||||
{
|
||||
clinicTenantId,
|
||||
labTenantId,
|
||||
status: "pending",
|
||||
requestedBy: ctx.user.id,
|
||||
requestedAt: new Date().toISOString(),
|
||||
},
|
||||
connectionPermissions(clinicTenantId, labTenantId),
|
||||
);
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "connection",
|
||||
entityId: created.$id,
|
||||
changes: { clinicTenantId, labTenantId, status: "pending" },
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Bağlantı talebi gönderilemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath("/connections");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function loadConnectionForMutation(
|
||||
connectionId: string,
|
||||
expectedTenant: string,
|
||||
): Promise<Connection | null> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = await tablesDB.getRow(DATABASE_ID, TABLES.connections, connectionId);
|
||||
const conn = row as unknown as Connection;
|
||||
if (
|
||||
conn.clinicTenantId !== expectedTenant &&
|
||||
conn.labTenantId !== expectedTenant
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return conn;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function approveConnectionAction(
|
||||
_prev: ConnectionActionState,
|
||||
formData: FormData,
|
||||
): Promise<ConnectionActionState> {
|
||||
const connectionId = String(formData.get("connectionId") ?? "").trim();
|
||||
if (!connectionId) return { ok: false, error: "Bağlantı bulunamadı." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Bu işlem için yetkiniz yok." };
|
||||
}
|
||||
|
||||
const conn = await loadConnectionForMutation(connectionId, ctx.tenantId);
|
||||
if (!conn) return { ok: false, error: "Bağlantı bulunamadı." };
|
||||
if (conn.status !== "pending") {
|
||||
return { ok: false, error: "Bu talep zaten yanıtlandı." };
|
||||
}
|
||||
// Requester cannot self-approve
|
||||
const requesterTenant =
|
||||
conn.requestedBy && conn.clinicTenantId === ctx.tenantId
|
||||
? conn.clinicTenantId
|
||||
: conn.labTenantId;
|
||||
// Better: simply require that current tenant is NOT the side that initiated.
|
||||
// We don't have requesterTenant explicit on row, but requestedBy.userId belongs
|
||||
// to one side. As a guard, approver must be on the other side from the user
|
||||
// who created it. Best signal we have: requestedBy is the originator userId.
|
||||
// If approver is same user as requester → block.
|
||||
if (conn.requestedBy === ctx.user.id) {
|
||||
return { ok: false, error: "Kendi talebinizi siz onaylayamazsınız." };
|
||||
}
|
||||
void requesterTenant;
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.connections, connectionId, {
|
||||
status: "approved",
|
||||
approvedAt: new Date().toISOString(),
|
||||
rejectedAt: null,
|
||||
});
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "connection",
|
||||
entityId: connectionId,
|
||||
changes: { status: "approved" },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Onaylanamadı.") };
|
||||
}
|
||||
|
||||
revalidatePath("/connections");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function rejectConnectionAction(
|
||||
_prev: ConnectionActionState,
|
||||
formData: FormData,
|
||||
): Promise<ConnectionActionState> {
|
||||
const connectionId = String(formData.get("connectionId") ?? "").trim();
|
||||
if (!connectionId) return { ok: false, error: "Bağlantı bulunamadı." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Bu işlem için yetkiniz yok." };
|
||||
}
|
||||
|
||||
const conn = await loadConnectionForMutation(connectionId, ctx.tenantId);
|
||||
if (!conn) return { ok: false, error: "Bağlantı bulunamadı." };
|
||||
if (conn.status !== "pending") {
|
||||
return { ok: false, error: "Bu talep zaten yanıtlandı." };
|
||||
}
|
||||
if (conn.requestedBy === ctx.user.id) {
|
||||
return { ok: false, error: "Kendi talebinizi reddedemezsiniz." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.connections, connectionId, {
|
||||
status: "rejected",
|
||||
rejectedAt: new Date().toISOString(),
|
||||
});
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "connection",
|
||||
entityId: connectionId,
|
||||
changes: { status: "rejected" },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Reddedilemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath("/connections");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function cancelConnectionAction(
|
||||
_prev: ConnectionActionState,
|
||||
formData: FormData,
|
||||
): Promise<ConnectionActionState> {
|
||||
// Used when the requester wants to withdraw their own pending request.
|
||||
const connectionId = String(formData.get("connectionId") ?? "").trim();
|
||||
if (!connectionId) return { ok: false, error: "Bağlantı bulunamadı." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Oturum bulunamadı." };
|
||||
}
|
||||
|
||||
const conn = await loadConnectionForMutation(connectionId, ctx.tenantId);
|
||||
if (!conn) return { ok: false, error: "Bağlantı bulunamadı." };
|
||||
if (conn.status !== "pending") {
|
||||
return { ok: false, error: "Bu talep iptal edilemez." };
|
||||
}
|
||||
if (conn.requestedBy !== ctx.user.id) {
|
||||
return { ok: false, error: "Sadece talebi gönderen iptal edebilir." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.connections, connectionId);
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "connection",
|
||||
entityId: connectionId,
|
||||
changes: { status: "pending", reason: "cancelled" },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "İptal edilemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath("/connections");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteConnectionAction(
|
||||
_prev: ConnectionActionState,
|
||||
formData: FormData,
|
||||
): Promise<ConnectionActionState> {
|
||||
const connectionId = String(formData.get("connectionId") ?? "").trim();
|
||||
if (!connectionId) return { ok: false, error: "Bağlantı bulunamadı." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Bu işlem için yetkiniz yok." };
|
||||
}
|
||||
|
||||
const conn = await loadConnectionForMutation(connectionId, ctx.tenantId);
|
||||
if (!conn) return { ok: false, error: "Bağlantı bulunamadı." };
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.connections, connectionId);
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "connection",
|
||||
entityId: connectionId,
|
||||
changes: { previousStatus: conn.status },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Silinemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath("/connections");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type Connection,
|
||||
type TenantKind,
|
||||
type TenantSettings,
|
||||
} from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
|
||||
export type CounterpartTenant = {
|
||||
tenantId: string;
|
||||
companyName: string;
|
||||
memberNumber: string;
|
||||
kind: TenantKind;
|
||||
};
|
||||
|
||||
export type ConnectionWithCounterpart = Connection & {
|
||||
counterpart: CounterpartTenant | null;
|
||||
};
|
||||
|
||||
function counterpartTenantId(conn: Connection, selfTenantId: string): string {
|
||||
return conn.clinicTenantId === selfTenantId ? conn.labTenantId : conn.clinicTenantId;
|
||||
}
|
||||
|
||||
async function fetchCounterparts(tenantIds: string[]): Promise<Map<string, CounterpartTenant>> {
|
||||
if (tenantIds.length === 0) return new Map();
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.tenantSettings,
|
||||
queries: [Query.equal("tenantId", tenantIds), Query.limit(100)],
|
||||
});
|
||||
const map = new Map<string, CounterpartTenant>();
|
||||
for (const row of result.rows as unknown as TenantSettings[]) {
|
||||
map.set(row.tenantId, {
|
||||
tenantId: row.tenantId,
|
||||
companyName: row.companyName,
|
||||
memberNumber: row.memberNumber,
|
||||
kind: row.kind,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function enrich(
|
||||
rows: Connection[],
|
||||
selfTenantId: string,
|
||||
): Promise<ConnectionWithCounterpart[]> {
|
||||
const counterpartIds = Array.from(
|
||||
new Set(rows.map((r) => counterpartTenantId(r, selfTenantId))),
|
||||
);
|
||||
const map = await fetchCounterparts(counterpartIds);
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
counterpart: map.get(counterpartTenantId(r, selfTenantId)) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async function listConnectionsByStatus(
|
||||
tenantId: string,
|
||||
status: Connection["status"],
|
||||
): Promise<Connection[]> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.connections,
|
||||
queries: [
|
||||
Query.or([
|
||||
Query.equal("clinicTenantId", tenantId),
|
||||
Query.equal("labTenantId", tenantId),
|
||||
]),
|
||||
Query.equal("status", status),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(100),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as Connection[];
|
||||
}
|
||||
|
||||
export async function listApprovedConnections(
|
||||
tenantId: string,
|
||||
): Promise<ConnectionWithCounterpart[]> {
|
||||
const rows = await listConnectionsByStatus(tenantId, "approved");
|
||||
return enrich(rows, tenantId);
|
||||
}
|
||||
|
||||
export async function listPendingInbound(
|
||||
tenantId: string,
|
||||
selfUserId: string,
|
||||
): Promise<ConnectionWithCounterpart[]> {
|
||||
// Pending requests sent TO this tenant (this tenant must approve/reject).
|
||||
const rows = await listConnectionsByStatus(tenantId, "pending");
|
||||
const inbound = rows.filter((r) => r.requestedBy !== selfUserId);
|
||||
return enrich(inbound, tenantId);
|
||||
}
|
||||
|
||||
export async function listPendingOutbound(
|
||||
tenantId: string,
|
||||
selfUserId: string,
|
||||
): Promise<ConnectionWithCounterpart[]> {
|
||||
// Pending requests this tenant sent — counterpart will approve/reject.
|
||||
const rows = await listConnectionsByStatus(tenantId, "pending");
|
||||
const outbound = rows.filter((r) => r.requestedBy === selfUserId);
|
||||
return enrich(outbound, tenantId);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export type ConnectionRequestState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const initialConnectionRequestState: ConnectionRequestState = { ok: false };
|
||||
|
||||
export type ConnectionActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const initialConnectionActionState: ConnectionActionState = { ok: false };
|
||||
@@ -0,0 +1,142 @@
|
||||
"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 Connection,
|
||||
} from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireRole, requireTenant, requireTenantKind } from "./tenant-guard";
|
||||
import type { JobFormState } from "./job-types";
|
||||
import { createJobSchema } from "@/lib/validation/job";
|
||||
|
||||
function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string {
|
||||
if (e instanceof AppwriteException) return e.message || fallback;
|
||||
return process.env.NODE_ENV !== "production" && e instanceof Error
|
||||
? `${fallback} (${e.message})`
|
||||
: fallback;
|
||||
}
|
||||
|
||||
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 pickFields(formData: FormData) {
|
||||
return {
|
||||
labTenantId: String(formData.get("labTenantId") ?? "").trim(),
|
||||
patientCode: String(formData.get("patientCode") ?? "").trim(),
|
||||
prostheticType: String(formData.get("prostheticType") ?? "").trim(),
|
||||
memberCount: String(formData.get("memberCount") ?? ""),
|
||||
color: String(formData.get("color") ?? "").trim(),
|
||||
description: String(formData.get("description") ?? "").trim(),
|
||||
price: String(formData.get("price") ?? "").trim(),
|
||||
currency: String(formData.get("currency") ?? "").trim(),
|
||||
dueDate: String(formData.get("dueDate") ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function jobPermissions(clinicTenantId: string, labTenantId: string): string[] {
|
||||
return [
|
||||
Permission.read(Role.team(clinicTenantId)),
|
||||
Permission.read(Role.team(labTenantId)),
|
||||
Permission.update(Role.team(clinicTenantId, "owner")),
|
||||
Permission.update(Role.team(clinicTenantId, "admin")),
|
||||
Permission.update(Role.team(clinicTenantId, "member")),
|
||||
Permission.update(Role.team(labTenantId, "owner")),
|
||||
Permission.update(Role.team(labTenantId, "admin")),
|
||||
Permission.update(Role.team(labTenantId, "member")),
|
||||
Permission.delete(Role.team(clinicTenantId, "owner")),
|
||||
Permission.delete(Role.team(clinicTenantId, "admin")),
|
||||
];
|
||||
}
|
||||
|
||||
export async function createJobAction(
|
||||
_prev: JobFormState,
|
||||
formData: FormData,
|
||||
): Promise<JobFormState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin", "member"]);
|
||||
requireTenantKind(ctx, ["clinic"]);
|
||||
} catch {
|
||||
return { ok: false, error: "İş yayınlama yalnızca klinik hesapları için." };
|
||||
}
|
||||
|
||||
const parsed = createJobSchema.safeParse(pickFields(formData));
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Form geçersiz.",
|
||||
fieldErrors: flattenErrors(parsed.error),
|
||||
};
|
||||
}
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
// Verify the chosen lab is an approved connection of this clinic
|
||||
const connRes = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.connections,
|
||||
queries: [
|
||||
Query.equal("clinicTenantId", ctx.tenantId),
|
||||
Query.equal("labTenantId", parsed.data.labTenantId),
|
||||
Query.equal("status", "approved"),
|
||||
Query.limit(1),
|
||||
],
|
||||
});
|
||||
const conn = connRes.rows[0] as unknown as Connection | undefined;
|
||||
if (!conn) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Seçilen laboratuvarla onaylanmış bir bağlantınız yok.",
|
||||
fieldErrors: { labTenantId: "Onaylı bağlantı bulunamadı." },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.jobs,
|
||||
ID.unique(),
|
||||
{
|
||||
clinicTenantId: ctx.tenantId,
|
||||
labTenantId: parsed.data.labTenantId,
|
||||
createdBy: ctx.user.id,
|
||||
patientCode: parsed.data.patientCode,
|
||||
prostheticType: parsed.data.prostheticType,
|
||||
memberCount: parsed.data.memberCount,
|
||||
color: parsed.data.color,
|
||||
description: parsed.data.description,
|
||||
price: parsed.data.price,
|
||||
currency: parsed.data.currency,
|
||||
dueDate: parsed.data.dueDate,
|
||||
status: "pending",
|
||||
},
|
||||
jobPermissions(ctx.tenantId, parsed.data.labTenantId),
|
||||
);
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "job",
|
||||
entityId: created.$id,
|
||||
changes: { labTenantId: parsed.data.labTenantId, patientCode: parsed.data.patientCode },
|
||||
});
|
||||
revalidatePath("/jobs/outbound");
|
||||
revalidatePath("/dashboard");
|
||||
return { ok: true, jobId: created.$id };
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "İş oluşturulamadı.") };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type Job,
|
||||
type TenantKind,
|
||||
type TenantSettings,
|
||||
} from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
|
||||
export type JobCounterpart = {
|
||||
tenantId: string;
|
||||
companyName: string;
|
||||
kind: TenantKind;
|
||||
};
|
||||
|
||||
export type JobWithCounterpart = Job & {
|
||||
counterpart: JobCounterpart | null;
|
||||
};
|
||||
|
||||
async function fetchTenants(tenantIds: string[]): Promise<Map<string, JobCounterpart>> {
|
||||
if (tenantIds.length === 0) return new Map();
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.tenantSettings,
|
||||
queries: [Query.equal("tenantId", tenantIds), Query.limit(200)],
|
||||
});
|
||||
const map = new Map<string, JobCounterpart>();
|
||||
for (const row of result.rows as unknown as TenantSettings[]) {
|
||||
map.set(row.tenantId, {
|
||||
tenantId: row.tenantId,
|
||||
companyName: row.companyName,
|
||||
kind: row.kind,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function enrichJob(j: Job, counterpartId: string, map: Map<string, JobCounterpart>): JobWithCounterpart {
|
||||
return { ...j, counterpart: map.get(counterpartId) ?? null };
|
||||
}
|
||||
|
||||
/** Inbound for a lab tenant — jobs the lab has received. */
|
||||
export async function listInboundJobs(labTenantId: string): Promise<JobWithCounterpart[]> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.jobs,
|
||||
queries: [
|
||||
Query.equal("labTenantId", labTenantId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(200),
|
||||
],
|
||||
});
|
||||
const jobs = result.rows as unknown as Job[];
|
||||
const map = await fetchTenants(Array.from(new Set(jobs.map((j) => j.clinicTenantId))));
|
||||
return jobs.map((j) => enrichJob(j, j.clinicTenantId, map));
|
||||
}
|
||||
|
||||
/** Outbound for a clinic tenant — jobs the clinic has sent. */
|
||||
export async function listOutboundJobs(clinicTenantId: string): Promise<JobWithCounterpart[]> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.jobs,
|
||||
queries: [
|
||||
Query.equal("clinicTenantId", clinicTenantId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(200),
|
||||
],
|
||||
});
|
||||
const jobs = result.rows as unknown as Job[];
|
||||
const map = await fetchTenants(Array.from(new Set(jobs.map((j) => j.labTenantId))));
|
||||
return jobs.map((j) => enrichJob(j, j.labTenantId, map));
|
||||
}
|
||||
|
||||
/** For clinic's "Yeni İş Yayınla" form: list approved labs they can send to. */
|
||||
export async function listApprovedLabsForClinic(
|
||||
clinicTenantId: string,
|
||||
): Promise<JobCounterpart[]> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.connections,
|
||||
queries: [
|
||||
Query.equal("clinicTenantId", clinicTenantId),
|
||||
Query.equal("status", "approved"),
|
||||
Query.limit(100),
|
||||
],
|
||||
});
|
||||
const labIds = (result.rows as unknown as { labTenantId: string }[]).map(
|
||||
(r) => r.labTenantId,
|
||||
);
|
||||
const map = await fetchTenants(labIds);
|
||||
return labIds
|
||||
.map((id) => map.get(id))
|
||||
.filter((v): v is JobCounterpart => Boolean(v));
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { JobStatus, JobStep, ProstheticType } from "./schema";
|
||||
|
||||
export type JobFormState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
jobId?: string;
|
||||
};
|
||||
|
||||
export const initialJobFormState: JobFormState = { ok: false };
|
||||
|
||||
export type JobActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const initialJobActionState: JobActionState = { ok: false };
|
||||
|
||||
export const JOB_STATUS_LABELS: Record<JobStatus, string> = {
|
||||
pending: "Bekliyor",
|
||||
in_progress: "İşlemde",
|
||||
sent: "Gönderildi",
|
||||
delivered: "Teslim alındı",
|
||||
cancelled: "İptal",
|
||||
};
|
||||
|
||||
export const JOB_STEP_LABELS: Record<JobStep, string> = {
|
||||
olcu: "Ölçü",
|
||||
alt_yapi_prova: "Alt Yapı Prova",
|
||||
ust_yapi_prova: "Üst Yapı Prova",
|
||||
cila_bitim: "Cila / Bitim",
|
||||
};
|
||||
|
||||
export const JOB_STEP_ORDER: JobStep[] = [
|
||||
"olcu",
|
||||
"alt_yapi_prova",
|
||||
"ust_yapi_prova",
|
||||
"cila_bitim",
|
||||
];
|
||||
|
||||
export const PROSTHETIC_TYPE_LABELS: Record<ProstheticType, string> = {
|
||||
metal_porselen: "Metal Porselen",
|
||||
zirkonyum: "Zirkonyum",
|
||||
implant_ustu_zirkonyum: "İmplant Üstü Zirkonyum",
|
||||
gecici: "Geçici",
|
||||
e_max: "E-Max",
|
||||
diger: "Diğer",
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, ID, Permission, Role } from "node-appwrite";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import { DATABASE_ID, TABLES, type Prosthetic } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireRole, requireTenant, requireTenantKind } from "./tenant-guard";
|
||||
import type { ProstheticActionState, ProstheticFormState } from "./prosthetic-types";
|
||||
import { prostheticSchema } from "@/lib/validation/prosthetic";
|
||||
|
||||
function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string {
|
||||
if (e instanceof AppwriteException) return e.message || fallback;
|
||||
return process.env.NODE_ENV !== "production" && e instanceof Error
|
||||
? `${fallback} (${e.message})`
|
||||
: fallback;
|
||||
}
|
||||
|
||||
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 pickFields(formData: FormData) {
|
||||
return {
|
||||
name: String(formData.get("name") ?? "").trim(),
|
||||
type: String(formData.get("type") ?? "").trim(),
|
||||
unitPrice: String(formData.get("unitPrice") ?? "0"),
|
||||
currency: String(formData.get("currency") ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function prostheticPermissions(tenantId: string): string[] {
|
||||
return [
|
||||
Permission.read(Role.team(tenantId)),
|
||||
Permission.update(Role.team(tenantId, "owner")),
|
||||
Permission.update(Role.team(tenantId, "admin")),
|
||||
Permission.delete(Role.team(tenantId, "owner")),
|
||||
Permission.delete(Role.team(tenantId, "admin")),
|
||||
];
|
||||
}
|
||||
|
||||
export async function createProstheticAction(
|
||||
_prev: ProstheticFormState,
|
||||
formData: FormData,
|
||||
): Promise<ProstheticFormState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin", "member"]);
|
||||
requireTenantKind(ctx, ["lab"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Bu işlem yalnızca laboratuvar hesaplarında yapılabilir." };
|
||||
}
|
||||
|
||||
const parsed = prostheticSchema.safeParse(pickFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const created = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.prosthetics,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
name: parsed.data.name,
|
||||
type: parsed.data.type,
|
||||
unitPrice: parsed.data.unitPrice,
|
||||
currency: parsed.data.currency,
|
||||
archived: false,
|
||||
},
|
||||
prostheticPermissions(ctx.tenantId),
|
||||
);
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "prosthetic",
|
||||
entityId: created.$id,
|
||||
changes: parsed.data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Ürün eklenemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath("/products");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateProstheticAction(
|
||||
_prev: ProstheticFormState,
|
||||
formData: FormData,
|
||||
): Promise<ProstheticFormState> {
|
||||
const id = String(formData.get("id") ?? "").trim();
|
||||
if (!id) return { ok: false, error: "Ürün bulunamadı." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin", "member"]);
|
||||
requireTenantKind(ctx, ["lab"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Bu işlem yalnızca laboratuvar hesaplarında yapılabilir." };
|
||||
}
|
||||
|
||||
const parsed = prostheticSchema.safeParse(pickFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.prosthetics,
|
||||
id,
|
||||
)) as unknown as Prosthetic;
|
||||
if (row.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Bu ürünü düzenleme yetkiniz yok." };
|
||||
}
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.prosthetics, id, {
|
||||
name: parsed.data.name,
|
||||
type: parsed.data.type,
|
||||
unitPrice: parsed.data.unitPrice,
|
||||
currency: parsed.data.currency,
|
||||
});
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "prosthetic",
|
||||
entityId: id,
|
||||
changes: parsed.data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Güncellenemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath("/products");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function archiveProstheticAction(
|
||||
_prev: ProstheticActionState,
|
||||
formData: FormData,
|
||||
): Promise<ProstheticActionState> {
|
||||
const id = String(formData.get("id") ?? "").trim();
|
||||
if (!id) return { ok: false, error: "Ürün bulunamadı." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin"]);
|
||||
requireTenantKind(ctx, ["lab"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Bu işlem için yetkiniz yok." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.prosthetics,
|
||||
id,
|
||||
)) as unknown as Prosthetic;
|
||||
if (row.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Bu ürünü düzenleme yetkiniz yok." };
|
||||
}
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.prosthetics, id, {
|
||||
archived: !row.archived,
|
||||
});
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "prosthetic",
|
||||
entityId: id,
|
||||
changes: { archived: !row.archived },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "İşlem başarısız.") };
|
||||
}
|
||||
|
||||
revalidatePath("/products");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteProstheticAction(
|
||||
_prev: ProstheticActionState,
|
||||
formData: FormData,
|
||||
): Promise<ProstheticActionState> {
|
||||
const id = String(formData.get("id") ?? "").trim();
|
||||
if (!id) return { ok: false, error: "Ürün bulunamadı." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin"]);
|
||||
requireTenantKind(ctx, ["lab"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Bu işlem için yetkiniz yok." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.prosthetics,
|
||||
id,
|
||||
)) as unknown as Prosthetic;
|
||||
if (row.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Bu ürünü silme yetkiniz yok." };
|
||||
}
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.prosthetics, id);
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "prosthetic",
|
||||
entityId: id,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Silinemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath("/products");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { DATABASE_ID, TABLES, type Prosthetic } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
|
||||
export async function listProsthetics(tenantId: string): Promise<Prosthetic[]> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.prosthetics,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderAsc("name"),
|
||||
Query.limit(200),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as Prosthetic[];
|
||||
}
|
||||
|
||||
export async function listActiveProsthetics(tenantId: string): Promise<Prosthetic[]> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.prosthetics,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.notEqual("archived", true),
|
||||
Query.orderAsc("name"),
|
||||
Query.limit(200),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as Prosthetic[];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type ProstheticFormState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const initialProstheticFormState: ProstheticFormState = { ok: false };
|
||||
|
||||
export type ProstheticActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const initialProstheticActionState: ProstheticActionState = { ok: false };
|
||||
|
||||
export const PROSTHETIC_TYPE_OPTIONS = [
|
||||
{ value: "metal_porselen", label: "Metal Porselen" },
|
||||
{ value: "zirkonyum", label: "Zirkonyum" },
|
||||
{ value: "implant_ustu_zirkonyum", label: "İmplant Üstü Zirkonyum" },
|
||||
{ value: "gecici", label: "Geçici" },
|
||||
{ value: "e_max", label: "E-Max" },
|
||||
{ value: "diger", label: "Diğer" },
|
||||
] as const;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const PROSTHETIC_TYPES = [
|
||||
"metal_porselen",
|
||||
"zirkonyum",
|
||||
"implant_ustu_zirkonyum",
|
||||
"gecici",
|
||||
"e_max",
|
||||
"diger",
|
||||
] as const;
|
||||
|
||||
export const createJobSchema = z.object({
|
||||
labTenantId: z.string().min(1, "Laboratuvar seçin."),
|
||||
patientCode: z.string().trim().min(1, "Hasta kodu zorunlu.").max(50),
|
||||
prostheticType: z.enum(PROSTHETIC_TYPES, { message: "Protez türü seçin." }),
|
||||
memberCount: z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((v) => {
|
||||
if (typeof v === "number") return v;
|
||||
const n = parseInt(v, 10);
|
||||
return Number.isFinite(n) ? n : NaN;
|
||||
})
|
||||
.pipe(z.number().int().min(1, "En az 1 üye.").max(32, "En fazla 32 üye.")),
|
||||
color: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(20)
|
||||
.optional()
|
||||
.transform((v) => (v ? v.toUpperCase() : undefined)),
|
||||
description: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(2000)
|
||||
.optional()
|
||||
.transform((v) => (v ? v : undefined)),
|
||||
price: z
|
||||
.union([z.string(), z.number()])
|
||||
.optional()
|
||||
.transform((v) => {
|
||||
if (v === undefined || v === "") return undefined;
|
||||
const n = typeof v === "number" ? v : Number(String(v).replace(",", "."));
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}),
|
||||
currency: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(8)
|
||||
.optional()
|
||||
.transform((v) => (v ? v.toUpperCase() : "TRY")),
|
||||
dueDate: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v ? new Date(v).toISOString() : undefined)),
|
||||
});
|
||||
|
||||
export type CreateJobInput = z.infer<typeof createJobSchema>;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const PROSTHETIC_TYPES = [
|
||||
"metal_porselen",
|
||||
"zirkonyum",
|
||||
"implant_ustu_zirkonyum",
|
||||
"gecici",
|
||||
"e_max",
|
||||
"diger",
|
||||
] as const;
|
||||
|
||||
export const prostheticSchema = z.object({
|
||||
name: z.string().trim().min(1, "Ürün adı zorunlu.").max(255),
|
||||
type: z.enum(PROSTHETIC_TYPES, { message: "Protez türü seçin." }),
|
||||
unitPrice: z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((v) => {
|
||||
if (typeof v === "number") return v;
|
||||
const n = Number(v.replace(",", "."));
|
||||
return Number.isFinite(n) ? n : NaN;
|
||||
})
|
||||
.pipe(z.number().min(0, "Negatif olamaz.")),
|
||||
currency: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(8)
|
||||
.optional()
|
||||
.transform((v) => (v ? v.toUpperCase() : "TRY")),
|
||||
});
|
||||
|
||||
export type ProstheticInput = z.infer<typeof prostheticSchema>;
|
||||
Reference in New Issue
Block a user