479972e9a9
Real prosthetic production isn't a one-way pipeline — the work moves
between lab and clinic multiple times. After substructure is produced
the lab hands it to the clinic for a fitting, the clinic approves it
back to the lab, the lab builds the superstructure, hands it back for
a second fitting, the clinic approves again, the lab does cila/bitim,
and finally delivers it to the clinic for handover to the patient.
Previously we only had a single 'advance step' action callable by the
lab, which collapsed all of that into a linear forward push and didn't
capture who physically had the work at any given moment.
DB
- New jobs.location enum (at_clinic | at_lab, default at_clinic).
- Existing jobs keep working via a 'location ?? at_lab' fallback in
code; no manual backfill required for the four test rows.
State machine
- acceptJobAction (lab): pending → in_progress, currentStep=alt_yapi_prova,
location=at_lab. Skips the implicit 'olcu' production step now that
accepting the job means the lab has the impression in hand.
- handToClinicAction (lab, NEW): at_lab → at_clinic, step stays the
same. If step is cila_bitim, status becomes 'sent' (final delivery)
and finance sync fires.
- approveAtClinicAction (clinic, NEW): at_clinic → at_lab, step
advances to the next stage so the lab knows what to produce next.
- markDeliveredAction unchanged — clinic confirms the final handoff.
- advanceStepAction removed; its single forward push doesn't fit the
new bidirectional flow.
UI
- JobActionsPanel now picks the right button from the role + status +
location matrix:
* Lab + pending → 'İşleme Al'
* Lab + in_progress + at_lab + cila_bitim → 'Cila Bitim — Nihai Teslime Gönder'
* Lab + in_progress + at_lab + other → '{stage} Provaya Gönder'
* Clinic + in_progress + at_clinic → '{stage} Provası Tamam'
* Clinic + sent → 'Teslim Aldım'
* Both + pending → 'İptal Et'
- Job detail surfaces a new 'Şu An' info row that resolves to a
human-readable location ('Klinikte', 'Laboratuvarda', 'Hasta'ya
teslim edildi', ...) so anyone glancing at the page can tell where
the work physically is.
627 lines
19 KiB
TypeScript
627 lines
19 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),
|
||
);
|
||
void logAudit({
|
||
tenantId: ctx.tenantId,
|
||
userId: ctx.user.id,
|
||
action: "create",
|
||
entityType: "job",
|
||
entityId: created.$id,
|
||
changes: { labTenantId: parsed.data.labTenantId, patientCode },
|
||
});
|
||
void 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();
|
||
// Accepting the job = lab took the impression, started substructure work.
|
||
// Step jumps straight to alt_yapi_prova; location flips to at_lab.
|
||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||
status: "in_progress",
|
||
currentStep: "alt_yapi_prova",
|
||
location: "at_lab",
|
||
});
|
||
await appendJobHistory({ job, step: "olcu", completedBy: ctx.user.id });
|
||
void logAudit({
|
||
tenantId: ctx.tenantId,
|
||
userId: ctx.user.id,
|
||
action: "update",
|
||
entityType: "job",
|
||
entityId: jobId,
|
||
changes: {
|
||
status: "in_progress",
|
||
currentStep: "alt_yapi_prova",
|
||
location: "at_lab",
|
||
},
|
||
});
|
||
void createNotification({
|
||
tenantId: job.clinicTenantId,
|
||
jobId,
|
||
message: `${ctx.settings?.companyName ?? "Lab"} hasta ${job.patientCode} işini işleme aldı, alt yapı üretiminde.`,
|
||
});
|
||
} catch (e) {
|
||
return { ok: false, error: appwriteError(e, "Kabul edilemedi.") };
|
||
}
|
||
|
||
revalidatePath(`/jobs/${jobId}`);
|
||
revalidatePath("/jobs/inbound");
|
||
revalidatePath("/jobs/outbound");
|
||
return { ok: true };
|
||
}
|
||
|
||
/**
|
||
* Lab hands the work back to the clinic for the next physical step
|
||
* (prova or final delivery). The current step stays the same — only the
|
||
* location flips at_lab → at_clinic. If the lab is finishing the last
|
||
* production step (cila_bitim), that's the final delivery and the job
|
||
* status becomes "sent".
|
||
*/
|
||
export async function handToClinicAction(
|
||
_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 kliniğe gönderebilir." };
|
||
}
|
||
|
||
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: "Sadece işlemdeki işler kliniğe gönderilebilir." };
|
||
}
|
||
if (job.location !== "at_lab") {
|
||
return { ok: false, error: "İş zaten kliniğe gönderilmiş." };
|
||
}
|
||
if (!job.currentStep) {
|
||
return { ok: false, error: "Mevcut aşama bilinmiyor." };
|
||
}
|
||
|
||
const isFinalStep = job.currentStep === "cila_bitim";
|
||
|
||
try {
|
||
const { tablesDB } = createAdminClient();
|
||
if (isFinalStep) {
|
||
// Final delivery — production is done, status moves to sent.
|
||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||
status: "sent",
|
||
location: "at_clinic",
|
||
});
|
||
await appendJobHistory({
|
||
job,
|
||
step: "cila_bitim",
|
||
completedBy: ctx.user.id,
|
||
note,
|
||
});
|
||
void logAudit({
|
||
tenantId: ctx.tenantId,
|
||
userId: ctx.user.id,
|
||
action: "update",
|
||
entityType: "job",
|
||
entityId: jobId,
|
||
changes: { status: "sent", location: "at_clinic" },
|
||
});
|
||
void syncFinanceForJob({ ...job, status: "sent" });
|
||
void createNotification({
|
||
tenantId: job.clinicTenantId,
|
||
jobId,
|
||
message: `Hasta ${job.patientCode} cila/bitim tamamlandı, nihai teslime gönderildi.`,
|
||
});
|
||
} else {
|
||
// Prova için klinike geçici teslim — step aynı, location değişti.
|
||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||
location: "at_clinic",
|
||
});
|
||
await appendJobHistory({
|
||
job,
|
||
step: job.currentStep,
|
||
completedBy: ctx.user.id,
|
||
note,
|
||
});
|
||
void logAudit({
|
||
tenantId: ctx.tenantId,
|
||
userId: ctx.user.id,
|
||
action: "update",
|
||
entityType: "job",
|
||
entityId: jobId,
|
||
changes: { location: "at_clinic", handedOffStep: job.currentStep },
|
||
});
|
||
const stepLabel =
|
||
job.currentStep === "alt_yapi_prova" ? "alt yapı" : "üst yapı";
|
||
void createNotification({
|
||
tenantId: job.clinicTenantId,
|
||
jobId,
|
||
message: `Hasta ${job.patientCode} ${stepLabel} provasına hazır, kliniğe gönderildi.`,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
return { ok: false, error: appwriteError(e, "Gönderilemedi.") };
|
||
}
|
||
|
||
revalidatePath(`/jobs/${jobId}`);
|
||
revalidatePath("/jobs/inbound");
|
||
revalidatePath("/jobs/outbound");
|
||
revalidatePath("/finance");
|
||
return { ok: true };
|
||
}
|
||
|
||
/**
|
||
* Clinic confirms the prova was successful. Step advances to the next
|
||
* production stage and location flips back at_clinic → at_lab so the
|
||
* lab can pick the work back up.
|
||
*/
|
||
export async function approveAtClinicAction(
|
||
_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, ["clinic"]);
|
||
} catch {
|
||
return { ok: false, error: "Sadece klinik provayı onaylayabilir." };
|
||
}
|
||
|
||
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||
if (!job || job.clinicTenantId !== ctx.tenantId) {
|
||
return { ok: false, error: "İş bulunamadı." };
|
||
}
|
||
if (job.status !== "in_progress") {
|
||
return { ok: false, error: "Yalnızca işlemdeki provalar onaylanabilir." };
|
||
}
|
||
if (job.location !== "at_clinic") {
|
||
return { ok: false, error: "İş şu an klinikte değil." };
|
||
}
|
||
if (!job.currentStep) {
|
||
return { ok: false, error: "Mevcut aşama bilinmiyor." };
|
||
}
|
||
|
||
const currentIdx = JOB_STEP_ORDER.indexOf(job.currentStep);
|
||
const nextStep = JOB_STEP_ORDER[currentIdx + 1];
|
||
if (!nextStep) {
|
||
return { ok: false, error: "Bu aşamadan ileri gidilemez." };
|
||
}
|
||
|
||
try {
|
||
const { tablesDB } = createAdminClient();
|
||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||
currentStep: nextStep,
|
||
location: "at_lab",
|
||
});
|
||
await appendJobHistory({
|
||
job,
|
||
step: job.currentStep,
|
||
completedBy: ctx.user.id,
|
||
note,
|
||
});
|
||
void logAudit({
|
||
tenantId: ctx.tenantId,
|
||
userId: ctx.user.id,
|
||
action: "update",
|
||
entityType: "job",
|
||
entityId: jobId,
|
||
changes: {
|
||
currentStep: nextStep,
|
||
location: "at_lab",
|
||
completedStep: job.currentStep,
|
||
},
|
||
});
|
||
const stepLabel =
|
||
job.currentStep === "alt_yapi_prova" ? "alt yapı" : "üst yapı";
|
||
void createNotification({
|
||
tenantId: job.labTenantId,
|
||
jobId,
|
||
message: `Hasta ${job.patientCode} ${stepLabel} provası onaylandı, lab tarafına geri döndü.`,
|
||
});
|
||
} catch (e) {
|
||
return { ok: false, error: appwriteError(e, "Onaylanamadı.") };
|
||
}
|
||
|
||
revalidatePath(`/jobs/${jobId}`);
|
||
revalidatePath("/jobs/inbound");
|
||
revalidatePath("/jobs/outbound");
|
||
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",
|
||
});
|
||
void logAudit({
|
||
tenantId: ctx.tenantId,
|
||
userId: ctx.user.id,
|
||
action: "update",
|
||
entityType: "job",
|
||
entityId: jobId,
|
||
changes: { status: "delivered" },
|
||
});
|
||
void syncFinanceForJob({ ...job, status: "delivered" });
|
||
void 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",
|
||
});
|
||
void 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 };
|
||
}
|