97a6031992
What the clinic sees has changed from a fixed 6-item Protez Türü dropdown
to the actual products that lab has published in its catalog. Picking 'Premium
Zirkonyum (1500 TRY / diş)' is now an option; picking the generic
'Zirkonyum' bucket is not.
Wiring
- DB: jobs.prostheticId string column (optional — legacy jobs stay valid).
The denormalised prostheticType is still written, sourced server-side
from the chosen catalog row, so reports/aggregates keep working.
- validation/job.ts: prostheticId required, prostheticType removed from
the form payload (computed instead).
- job-actions.ts: looks up the catalog row, verifies tenantId match +
not archived, then pulls .type and .unitPrice from it to drive
calculateJobPriceForProsthetic.
Pricing helper
- New calculateJobPriceForProsthetic(prosthetic, clinicTenantId,
teethCount). Reuses the same clinic_pricing cascade
(custom price wins, otherwise discountPercent off catalog) but skips
the type-based catalog lookup entirely — it already has the row.
- /api/pricing/quote rewritten to accept { prostheticId, teethCount },
still gated on an approved connection between clinic and the lab that
owns the product.
UI
- jobs/new page server-loads each connected lab's active prosthetics
once and hands them to NewJobForm as prostheticsByLab.
- NewJobForm: 'Ürün *' Select replaces 'Protez Türü *'. The list filters
by the currently selected lab; switching labs clears the chosen
product. Each option shows name + 'unitPrice / diş' on the right.
Once selected, a small caption surfaces the underlying category
label ('Kategori: Zirkonyum') so the clinic still understands what
bucket the product falls into.
- PriceQuoteCard's hasInputs gating moved off prostheticType to
prostheticId.
522 lines
16 KiB
TypeScript
522 lines
16 KiB
TypeScript
"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 { syncFinanceForJob } from "./finance-sync";
|
||
import { createNotification } from "./notification-helpers";
|
||
import { calculateJobPriceForProsthetic } from "./pricing";
|
||
import {
|
||
DATABASE_ID,
|
||
TABLES,
|
||
type Connection,
|
||
type Job,
|
||
type JobStep,
|
||
type Patient,
|
||
type Prosthetic,
|
||
} from "./schema";
|
||
import { createAdminClient } from "./server";
|
||
import { requireRole, requireTenant, requireTenantKind } from "./tenant-guard";
|
||
import { JOB_STEP_ORDER } from "./job-types";
|
||
import type { JobActionState, 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(),
|
||
patientId: String(formData.get("patientId") ?? "").trim(),
|
||
patientCode: String(formData.get("patientCode") ?? "").trim(),
|
||
prostheticId: String(formData.get("prostheticId") ?? "").trim(),
|
||
teeth: formData.getAll("teeth").map((v) => String(v).trim()).filter(Boolean),
|
||
color: String(formData.get("color") ?? "").trim(),
|
||
description: String(formData.get("description") ?? "").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();
|
||
|
||
// If a patientId is supplied, verify it belongs to this clinic and inherit
|
||
// its patientCode so the lab side always sees a stable identifier.
|
||
let patientCode = parsed.data.patientCode;
|
||
if (parsed.data.patientId) {
|
||
try {
|
||
const patientRow = (await tablesDB.getRow(
|
||
DATABASE_ID,
|
||
TABLES.patients,
|
||
parsed.data.patientId,
|
||
)) as unknown as Patient;
|
||
if (patientRow.clinicTenantId !== ctx.tenantId) {
|
||
return {
|
||
ok: false,
|
||
error: "Bu hasta size ait değil.",
|
||
fieldErrors: { patientId: "Yetki yok." },
|
||
};
|
||
}
|
||
patientCode = patientRow.patientCode;
|
||
} catch {
|
||
return {
|
||
ok: false,
|
||
error: "Hasta bulunamadı.",
|
||
fieldErrors: { patientId: "Hasta kaydı yok." },
|
||
};
|
||
}
|
||
}
|
||
|
||
// 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ı." },
|
||
};
|
||
}
|
||
|
||
// Resolve the chosen catalog product. It must belong to the selected lab
|
||
// and still be active — anything else is rejected with a clear error.
|
||
let prosthetic: Prosthetic;
|
||
try {
|
||
const row = await tablesDB.getRow(
|
||
DATABASE_ID,
|
||
TABLES.prosthetics,
|
||
parsed.data.prostheticId,
|
||
);
|
||
prosthetic = row as unknown as Prosthetic;
|
||
} catch {
|
||
return {
|
||
ok: false,
|
||
error: "Seçilen ürün bulunamadı.",
|
||
fieldErrors: { prostheticId: "Ürün bulunamadı." },
|
||
};
|
||
}
|
||
if (prosthetic.tenantId !== parsed.data.labTenantId) {
|
||
return {
|
||
ok: false,
|
||
error: "Seçilen ürün bu laboratuvara ait değil.",
|
||
fieldErrors: { prostheticId: "Ürün bu lab'a ait değil." },
|
||
};
|
||
}
|
||
if (prosthetic.archived) {
|
||
return {
|
||
ok: false,
|
||
error: "Seçilen ürün arşivlenmiş.",
|
||
fieldErrors: { prostheticId: "Bu ürün artık aktif değil." },
|
||
};
|
||
}
|
||
|
||
try {
|
||
// Server-side price calculation — clinic never sets the price.
|
||
const quote = await calculateJobPriceForProsthetic({
|
||
prosthetic,
|
||
clinicTenantId: ctx.tenantId,
|
||
teethCount: parsed.data.teeth.length,
|
||
});
|
||
|
||
const created = await tablesDB.createRow(
|
||
DATABASE_ID,
|
||
TABLES.jobs,
|
||
ID.unique(),
|
||
{
|
||
clinicTenantId: ctx.tenantId,
|
||
labTenantId: parsed.data.labTenantId,
|
||
createdBy: ctx.user.id,
|
||
patientId: parsed.data.patientId,
|
||
patientCode,
|
||
prostheticType: prosthetic.type,
|
||
prostheticId: prosthetic.$id,
|
||
memberCount: parsed.data.teeth.length,
|
||
teeth: parsed.data.teeth,
|
||
color: parsed.data.color,
|
||
description: parsed.data.description,
|
||
price: quote?.amount,
|
||
currency: quote?.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 },
|
||
});
|
||
await createNotification({
|
||
tenantId: parsed.data.labTenantId,
|
||
jobId: created.$id,
|
||
message: `${ctx.settings?.companyName ?? "Bir klinik"} yeni iş yayınladı (${patientCode}).`,
|
||
});
|
||
revalidatePath("/jobs/outbound");
|
||
revalidatePath("/dashboard");
|
||
return { ok: true, jobId: created.$id };
|
||
} catch (e) {
|
||
return { ok: false, error: appwriteError(e, "İş oluşturulamadı.") };
|
||
}
|
||
}
|
||
|
||
function jobHistoryPermissions(clinicTenantId: string, labTenantId: string): string[] {
|
||
return [
|
||
Permission.read(Role.team(clinicTenantId)),
|
||
Permission.read(Role.team(labTenantId)),
|
||
];
|
||
}
|
||
|
||
async function loadJobForTenant(jobId: string, tenantId: string): Promise<Job | null> {
|
||
try {
|
||
const { tablesDB } = createAdminClient();
|
||
const row = await tablesDB.getRow(DATABASE_ID, TABLES.jobs, jobId);
|
||
const job = row as unknown as Job;
|
||
if (job.clinicTenantId !== tenantId && job.labTenantId !== tenantId) {
|
||
return null;
|
||
}
|
||
return job;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function appendJobHistory(args: {
|
||
job: Job;
|
||
step: JobStep;
|
||
completedBy: string;
|
||
note?: string;
|
||
}): Promise<void> {
|
||
const { tablesDB } = createAdminClient();
|
||
try {
|
||
await tablesDB.createRow(
|
||
DATABASE_ID,
|
||
TABLES.jobStatusHistory,
|
||
ID.unique(),
|
||
{
|
||
jobId: args.job.$id,
|
||
clinicTenantId: args.job.clinicTenantId,
|
||
labTenantId: args.job.labTenantId,
|
||
step: args.step,
|
||
completedBy: args.completedBy,
|
||
completedAt: new Date().toISOString(),
|
||
note: args.note,
|
||
},
|
||
jobHistoryPermissions(args.job.clinicTenantId, args.job.labTenantId),
|
||
);
|
||
} catch {
|
||
// history failures must never block the main mutation
|
||
}
|
||
}
|
||
|
||
export async function acceptJobAction(
|
||
_prev: JobActionState,
|
||
formData: FormData,
|
||
): Promise<JobActionState> {
|
||
const jobId = String(formData.get("jobId") ?? "").trim();
|
||
if (!jobId) return { ok: false, error: "İş bulunamadı." };
|
||
|
||
let ctx;
|
||
try {
|
||
ctx = await requireTenant();
|
||
requireRole(ctx, ["owner", "admin", "member"]);
|
||
requireTenantKind(ctx, ["lab"]);
|
||
} catch {
|
||
return { ok: false, error: "Sadece laboratuvar bu işi kabul edebilir." };
|
||
}
|
||
|
||
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||
if (!job || job.labTenantId !== ctx.tenantId) {
|
||
return { ok: false, error: "İş bulunamadı." };
|
||
}
|
||
if (job.status !== "pending") {
|
||
return { ok: false, error: "Bu iş zaten işleme alınmış." };
|
||
}
|
||
|
||
try {
|
||
const { tablesDB } = createAdminClient();
|
||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||
status: "in_progress",
|
||
currentStep: "olcu",
|
||
});
|
||
await appendJobHistory({ job, step: "olcu", completedBy: ctx.user.id });
|
||
await logAudit({
|
||
tenantId: ctx.tenantId,
|
||
userId: ctx.user.id,
|
||
action: "update",
|
||
entityType: "job",
|
||
entityId: jobId,
|
||
changes: { status: "in_progress", currentStep: "olcu" },
|
||
});
|
||
await createNotification({
|
||
tenantId: job.clinicTenantId,
|
||
jobId,
|
||
message: `${ctx.settings?.companyName ?? "Lab"} hasta ${job.patientCode} işini işleme aldı.`,
|
||
});
|
||
} catch (e) {
|
||
return { ok: false, error: appwriteError(e, "Kabul edilemedi.") };
|
||
}
|
||
|
||
revalidatePath(`/jobs/${jobId}`);
|
||
revalidatePath("/jobs/inbound");
|
||
revalidatePath("/jobs/outbound");
|
||
return { ok: true };
|
||
}
|
||
|
||
export async function advanceStepAction(
|
||
_prev: JobActionState,
|
||
formData: FormData,
|
||
): Promise<JobActionState> {
|
||
const jobId = String(formData.get("jobId") ?? "").trim();
|
||
if (!jobId) return { ok: false, error: "İş bulunamadı." };
|
||
const note = String(formData.get("note") ?? "").trim() || undefined;
|
||
|
||
let ctx;
|
||
try {
|
||
ctx = await requireTenant();
|
||
requireRole(ctx, ["owner", "admin", "member"]);
|
||
requireTenantKind(ctx, ["lab"]);
|
||
} catch {
|
||
return { ok: false, error: "Sadece laboratuvar aşama ilerletebilir." };
|
||
}
|
||
|
||
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||
if (!job || job.labTenantId !== ctx.tenantId) {
|
||
return { ok: false, error: "İş bulunamadı." };
|
||
}
|
||
if (job.status !== "in_progress") {
|
||
return { ok: false, error: "Yalnızca işleme alınmış işler ilerletilebilir." };
|
||
}
|
||
const currentIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1;
|
||
if (currentIdx < 0) return { ok: false, error: "Mevcut aşama bilinmiyor." };
|
||
|
||
const nextIdx = currentIdx + 1;
|
||
const isFinalStepComplete = currentIdx === JOB_STEP_ORDER.length - 1;
|
||
|
||
try {
|
||
const { tablesDB } = createAdminClient();
|
||
if (isFinalStepComplete) {
|
||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||
status: "sent",
|
||
});
|
||
await logAudit({
|
||
tenantId: ctx.tenantId,
|
||
userId: ctx.user.id,
|
||
action: "update",
|
||
entityType: "job",
|
||
entityId: jobId,
|
||
changes: { status: "sent" },
|
||
});
|
||
} else {
|
||
const nextStep = JOB_STEP_ORDER[nextIdx];
|
||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||
currentStep: nextStep,
|
||
});
|
||
await appendJobHistory({
|
||
job,
|
||
step: job.currentStep!,
|
||
completedBy: ctx.user.id,
|
||
note,
|
||
});
|
||
await logAudit({
|
||
tenantId: ctx.tenantId,
|
||
userId: ctx.user.id,
|
||
action: "update",
|
||
entityType: "job",
|
||
entityId: jobId,
|
||
changes: { currentStep: nextStep, completedStep: job.currentStep },
|
||
});
|
||
}
|
||
} catch (e) {
|
||
return { ok: false, error: appwriteError(e, "İlerletilemedi.") };
|
||
}
|
||
|
||
if (isFinalStepComplete) {
|
||
// Record completion of the last step too, then mark sent.
|
||
await appendJobHistory({
|
||
job,
|
||
step: job.currentStep!,
|
||
completedBy: ctx.user.id,
|
||
note,
|
||
});
|
||
await syncFinanceForJob({ ...job, status: "sent" });
|
||
await createNotification({
|
||
tenantId: job.clinicTenantId,
|
||
jobId,
|
||
message: `Hasta ${job.patientCode} işi gönderildi. Teslim alındığında onaylayın.`,
|
||
});
|
||
}
|
||
|
||
revalidatePath(`/jobs/${jobId}`);
|
||
revalidatePath("/jobs/inbound");
|
||
revalidatePath("/jobs/outbound");
|
||
revalidatePath("/finance");
|
||
return { ok: true };
|
||
}
|
||
|
||
export async function markDeliveredAction(
|
||
_prev: JobActionState,
|
||
formData: FormData,
|
||
): Promise<JobActionState> {
|
||
const jobId = String(formData.get("jobId") ?? "").trim();
|
||
if (!jobId) return { ok: false, error: "İş bulunamadı." };
|
||
|
||
let ctx;
|
||
try {
|
||
ctx = await requireTenant();
|
||
requireRole(ctx, ["owner", "admin", "member"]);
|
||
requireTenantKind(ctx, ["clinic"]);
|
||
} catch {
|
||
return { ok: false, error: "Teslim almayı yalnızca klinik yapabilir." };
|
||
}
|
||
|
||
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||
if (!job || job.clinicTenantId !== ctx.tenantId) {
|
||
return { ok: false, error: "İş bulunamadı." };
|
||
}
|
||
if (job.status !== "sent") {
|
||
return { ok: false, error: "Sadece gönderilmiş işler teslim alınabilir." };
|
||
}
|
||
|
||
try {
|
||
const { tablesDB } = createAdminClient();
|
||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||
status: "delivered",
|
||
});
|
||
await logAudit({
|
||
tenantId: ctx.tenantId,
|
||
userId: ctx.user.id,
|
||
action: "update",
|
||
entityType: "job",
|
||
entityId: jobId,
|
||
changes: { status: "delivered" },
|
||
});
|
||
await syncFinanceForJob({ ...job, status: "delivered" });
|
||
await createNotification({
|
||
tenantId: job.labTenantId,
|
||
jobId,
|
||
message: `Hasta ${job.patientCode} işi teslim alındı.`,
|
||
});
|
||
} catch (e) {
|
||
return { ok: false, error: appwriteError(e, "Teslim alınamadı.") };
|
||
}
|
||
|
||
revalidatePath(`/jobs/${jobId}`);
|
||
revalidatePath("/jobs/outbound");
|
||
revalidatePath("/jobs/inbound");
|
||
revalidatePath("/finance");
|
||
return { ok: true };
|
||
}
|
||
|
||
export async function cancelJobAction(
|
||
_prev: JobActionState,
|
||
formData: FormData,
|
||
): Promise<JobActionState> {
|
||
const jobId = String(formData.get("jobId") ?? "").trim();
|
||
if (!jobId) return { ok: false, error: "İş bulunamadı." };
|
||
|
||
let ctx;
|
||
try {
|
||
ctx = await requireTenant();
|
||
requireRole(ctx, ["owner", "admin"]);
|
||
} catch {
|
||
return { ok: false, error: "Bu işlem için yetkiniz yok." };
|
||
}
|
||
|
||
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||
if (!job) return { ok: false, error: "İş bulunamadı." };
|
||
if (job.status !== "pending") {
|
||
return { ok: false, error: "Yalnızca bekleyen işler iptal edilebilir." };
|
||
}
|
||
if (ctx.kind === "clinic" && job.clinicTenantId !== ctx.tenantId) {
|
||
return { ok: false, error: "Yetkiniz yok." };
|
||
}
|
||
if (ctx.kind === "lab" && job.labTenantId !== ctx.tenantId) {
|
||
return { ok: false, error: "Yetkiniz yok." };
|
||
}
|
||
|
||
try {
|
||
const { tablesDB } = createAdminClient();
|
||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||
status: "cancelled",
|
||
});
|
||
await logAudit({
|
||
tenantId: ctx.tenantId,
|
||
userId: ctx.user.id,
|
||
action: "update",
|
||
entityType: "job",
|
||
entityId: jobId,
|
||
changes: { status: "cancelled" },
|
||
});
|
||
} catch (e) {
|
||
return { ok: false, error: appwriteError(e, "İptal edilemedi.") };
|
||
}
|
||
|
||
revalidatePath(`/jobs/${jobId}`);
|
||
revalidatePath("/jobs/inbound");
|
||
revalidatePath("/jobs/outbound");
|
||
return { ok: true };
|
||
}
|