Compare commits
2 Commits
68f82d79c2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2762aceb04 | |||
| f3442e644a |
@@ -249,23 +249,34 @@ export default async function DashboardPage() {
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{data.recentNotifications.map((n) => (
|
||||
{data.recentNotifications.map((n) => {
|
||||
const isWarning = n.severity === "warning";
|
||||
return (
|
||||
<li
|
||||
key={n.$id}
|
||||
className={`flex items-start gap-3 py-2.5 ${n.read ? "opacity-70" : ""}`}
|
||||
>
|
||||
<span
|
||||
className={`mt-1.5 size-2 shrink-0 rounded-full ${n.read ? "bg-muted" : "bg-primary"}`}
|
||||
className={`mt-1.5 size-2 shrink-0 rounded-full ${
|
||||
n.read
|
||||
? "bg-muted"
|
||||
: isWarning
|
||||
? "bg-amber-500"
|
||||
: "bg-primary"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm leading-tight">{n.message}</p>
|
||||
<p className={`text-sm leading-tight ${isWarning && !n.read ? "font-medium" : ""}`}>
|
||||
{n.message}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{datetimeFormatter.format(new Date(n.$createdAt))}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
@@ -87,17 +86,13 @@ export function JobActionsPanel({
|
||||
}
|
||||
|
||||
function AcceptButton({ jobId }: { jobId: string }) {
|
||||
const router = useRouter();
|
||||
const [state, action, pending] = useActionState(acceptJobAction, initialJobActionState);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("İş işleme alındı, alt yapı üretimi başladı.");
|
||||
router.refresh();
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state, router]);
|
||||
// Success path redirects from the server action, so state.ok never
|
||||
// shows up here — we only need to surface errors.
|
||||
if (state.error) toast.error(state.error);
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<form action={action}>
|
||||
@@ -111,19 +106,12 @@ function AcceptButton({ jobId }: { jobId: string }) {
|
||||
}
|
||||
|
||||
function HandToClinicButton({ job }: { job: Job }) {
|
||||
const router = useRouter();
|
||||
const [state, action, pending] = useActionState(handToClinicAction, initialJobActionState);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("Klinik tarafına gönderildi.");
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state, router]);
|
||||
if (state.error) toast.error(state.error);
|
||||
}, [state]);
|
||||
|
||||
const isFinal = job.currentStep === "cila_bitim";
|
||||
const stageLabel =
|
||||
@@ -180,19 +168,12 @@ function HandToClinicButton({ job }: { job: Job }) {
|
||||
}
|
||||
|
||||
function ApproveAtClinicButton({ job }: { job: Job }) {
|
||||
const router = useRouter();
|
||||
const [state, action, pending] = useActionState(approveAtClinicAction, initialJobActionState);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("Prova onaylandı, lab tarafına gönderildi.");
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state, router]);
|
||||
if (state.error) toast.error(state.error);
|
||||
}, [state]);
|
||||
|
||||
const stageLabel = job.currentStep === "alt_yapi_prova" ? "alt yapı" : "üst yapı";
|
||||
|
||||
@@ -242,7 +223,6 @@ function ApproveAtClinicButton({ job }: { job: Job }) {
|
||||
}
|
||||
|
||||
function RequestRevisionButton({ job }: { job: Job }) {
|
||||
const router = useRouter();
|
||||
const [state, action, pending] = useActionState(
|
||||
requestRevisionAction,
|
||||
initialJobActionState,
|
||||
@@ -250,14 +230,8 @@ function RequestRevisionButton({ job }: { job: Job }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("Düzeltme talebi gönderildi.");
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state, router]);
|
||||
if (state.error) toast.error(state.error);
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -304,17 +278,11 @@ function RequestRevisionButton({ job }: { job: Job }) {
|
||||
}
|
||||
|
||||
function DeliverButton({ jobId }: { jobId: string }) {
|
||||
const router = useRouter();
|
||||
const [state, action, pending] = useActionState(markDeliveredAction, initialJobActionState);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("İş teslim alındı.");
|
||||
router.refresh();
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state, router]);
|
||||
if (state.error) toast.error(state.error);
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<form action={action}>
|
||||
@@ -328,19 +296,12 @@ function DeliverButton({ jobId }: { jobId: string }) {
|
||||
}
|
||||
|
||||
function CancelButton({ jobId }: { jobId: string }) {
|
||||
const router = useRouter();
|
||||
const [state, action, pending] = useActionState(cancelJobAction, initialJobActionState);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("İş iptal edildi.");
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state, router]);
|
||||
if (state.error) toast.error(state.error);
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Suspense } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { FlashToast } from "@/components/flash-toast";
|
||||
import { getActiveContext } from "@/lib/appwrite/active-context";
|
||||
import { countUnreadNotifications } from "@/lib/appwrite/notification-helpers";
|
||||
import { getLogoUrl } from "@/lib/appwrite/storage";
|
||||
@@ -40,6 +42,9 @@ export default async function DashboardLayout({
|
||||
unreadCount={unreadCount}
|
||||
>
|
||||
{children}
|
||||
<Suspense fallback={null}>
|
||||
<FlashToast />
|
||||
</Suspense>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,10 +66,21 @@ function NotificationRow({ row }: { row: Notification }) {
|
||||
? "/connections"
|
||||
: null;
|
||||
|
||||
const isWarning = row.severity === "warning";
|
||||
return (
|
||||
<li className={`flex items-start gap-3 px-3 py-3 ${row.read ? "opacity-70" : ""}`}>
|
||||
<li
|
||||
className={`flex items-start gap-3 px-3 py-3 ${row.read ? "opacity-70" : ""} ${
|
||||
isWarning && !row.read ? "bg-amber-50/60 dark:bg-amber-950/30" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`mt-1.5 size-2 shrink-0 rounded-full ${row.read ? "bg-muted" : "bg-primary"}`}
|
||||
className={`mt-1.5 size-2 shrink-0 rounded-full ${
|
||||
row.read
|
||||
? "bg-muted"
|
||||
: isWarning
|
||||
? "bg-amber-500"
|
||||
: "bg-primary"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -79,8 +90,11 @@ function NotificationRow({ row }: { row: Notification }) {
|
||||
</p>
|
||||
</div>
|
||||
{!row.read && (
|
||||
<Badge variant="secondary" className="text-[10px] uppercase">
|
||||
Yeni
|
||||
<Badge
|
||||
variant={isWarning ? "destructive" : "secondary"}
|
||||
className="text-[10px] uppercase"
|
||||
>
|
||||
{isWarning ? "Dikkat" : "Yeni"}
|
||||
</Badge>
|
||||
)}
|
||||
{link && (
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const MESSAGES: Record<string, string> = {
|
||||
accepted: "İş işleme alındı, alt yapı üretimi başladı.",
|
||||
handed: "Klinik tarafına gönderildi.",
|
||||
approved: "Prova onaylandı, lab tarafına geri gönderildi.",
|
||||
revision: "Düzeltme talebi gönderildi.",
|
||||
delivered: "İş teslim alındı.",
|
||||
cancelled: "İş iptal edildi.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Show a one-shot toast based on ?flash=<key>, then strip the param from
|
||||
* the URL so a refresh doesn't replay it. Mounted in the dashboard layout
|
||||
* so it works on every page that server actions might redirect to.
|
||||
*/
|
||||
export function FlashToast() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useSearchParams();
|
||||
const fired = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const flash = params.get("flash");
|
||||
if (!flash) return;
|
||||
// Avoid double-firing under React Strict Mode in dev.
|
||||
if (fired.current === flash) return;
|
||||
fired.current = flash;
|
||||
|
||||
const message = MESSAGES[flash] ?? null;
|
||||
if (message) toast.success(message);
|
||||
|
||||
const next = new URLSearchParams(params.toString());
|
||||
next.delete("flash");
|
||||
const query = next.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
||||
}, [params, pathname, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -278,6 +278,15 @@ export async function rejectConnectionAction(
|
||||
entityId: connectionId,
|
||||
changes: { status: "rejected" },
|
||||
});
|
||||
// Tell the requester their request was turned down — warning, not info.
|
||||
const requesterTenant =
|
||||
conn.clinicTenantId === ctx.tenantId ? conn.labTenantId : conn.clinicTenantId;
|
||||
void createNotification({
|
||||
tenantId: requesterTenant,
|
||||
connectionId,
|
||||
severity: "warning",
|
||||
message: `${ctx.settings?.companyName ?? "Karşı taraf"} bağlantı talebinizi reddetti.`,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Reddedilemedi.") };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -330,7 +331,9 @@ export async function acceptJobAction(
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
revalidatePath("/jobs/inbound");
|
||||
revalidatePath("/jobs/outbound");
|
||||
return { ok: true };
|
||||
// Redirect forces a full RSC payload reload — bypasses any client-side
|
||||
// cache that router.refresh() might otherwise miss.
|
||||
redirect(`/jobs/${jobId}?flash=accepted`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -498,7 +501,7 @@ export async function handToClinicAction(
|
||||
revalidatePath("/jobs/inbound");
|
||||
revalidatePath("/jobs/outbound");
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
redirect(`/jobs/${jobId}?flash=handed`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -581,7 +584,7 @@ export async function approveAtClinicAction(
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
revalidatePath("/jobs/inbound");
|
||||
revalidatePath("/jobs/outbound");
|
||||
return { ok: true };
|
||||
redirect(`/jobs/${jobId}?flash=approved`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -661,6 +664,7 @@ export async function requestRevisionAction(
|
||||
void createNotification({
|
||||
tenantId: job.labTenantId,
|
||||
jobId,
|
||||
severity: "warning",
|
||||
message: `Hasta ${job.patientCode} ${stepLabel} provası için düzeltme istendi: ${note.slice(0, 120)}`,
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -670,7 +674,7 @@ export async function requestRevisionAction(
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
revalidatePath("/jobs/inbound");
|
||||
revalidatePath("/jobs/outbound");
|
||||
return { ok: true };
|
||||
redirect(`/jobs/${jobId}?flash=revision`);
|
||||
}
|
||||
|
||||
export async function markDeliveredAction(
|
||||
@@ -727,7 +731,7 @@ export async function markDeliveredAction(
|
||||
revalidatePath("/jobs/outbound");
|
||||
revalidatePath("/jobs/inbound");
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
redirect(`/jobs/${jobId}?flash=delivered`);
|
||||
}
|
||||
|
||||
export async function cancelJobAction(
|
||||
@@ -770,6 +774,16 @@ export async function cancelJobAction(
|
||||
entityId: jobId,
|
||||
changes: { status: "cancelled" },
|
||||
});
|
||||
// Notify the other side — cancellation is a warning, not normal traffic.
|
||||
const otherTenantId =
|
||||
ctx.tenantId === job.clinicTenantId ? job.labTenantId : job.clinicTenantId;
|
||||
const actor = ctx.kind === "lab" ? "Laboratuvar" : "Klinik";
|
||||
void createNotification({
|
||||
tenantId: otherTenantId,
|
||||
jobId,
|
||||
severity: "warning",
|
||||
message: `${actor} hasta ${job.patientCode} işini iptal etti.`,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "İptal edilemedi.") };
|
||||
}
|
||||
@@ -777,5 +791,5 @@ export async function cancelJobAction(
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
revalidatePath("/jobs/inbound");
|
||||
revalidatePath("/jobs/outbound");
|
||||
return { ok: true };
|
||||
redirect(`/jobs/${jobId}?flash=cancelled`);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ import "server-only";
|
||||
|
||||
import { ID, Permission, Query, Role } from "node-appwrite";
|
||||
|
||||
import { DATABASE_ID, TABLES, type Notification } from "./schema";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type Notification,
|
||||
type NotificationSeverity,
|
||||
} from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { toPlain } from "./serialize";
|
||||
|
||||
@@ -12,6 +17,9 @@ type CreateNotificationInput = {
|
||||
jobId?: string;
|
||||
connectionId?: string;
|
||||
message: string;
|
||||
/** Defaults to 'info'. Use 'warning' for things that need the user's
|
||||
* attention (revision, cancellation, rejections). */
|
||||
severity?: NotificationSeverity;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -32,6 +40,7 @@ export async function createNotification(input: CreateNotificationInput): Promis
|
||||
connectionId: input.connectionId,
|
||||
message: input.message.slice(0, 500),
|
||||
read: false,
|
||||
severity: input.severity ?? "info",
|
||||
},
|
||||
[
|
||||
Permission.read(Role.team(input.tenantId)),
|
||||
|
||||
@@ -269,6 +269,7 @@ export async function rejectPaymentAction(
|
||||
});
|
||||
void createNotification({
|
||||
tenantId: row.tenantId,
|
||||
severity: "warning",
|
||||
message: `Ödeme bildiriminiz reddedildi: ${row.amount.toLocaleString("tr-TR")} ${row.currency}.`,
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
@@ -194,6 +194,8 @@ export interface Payment extends Row {
|
||||
recordedBy: string;
|
||||
}
|
||||
|
||||
export type NotificationSeverity = "info" | "warning";
|
||||
|
||||
export interface Notification extends Row {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
@@ -201,6 +203,9 @@ export interface Notification extends Row {
|
||||
connectionId?: string;
|
||||
message: string;
|
||||
read: boolean;
|
||||
/** Visual + filtering hint. 'warning' for things requiring attention
|
||||
* (revision request, cancellation, payment / connection rejection). */
|
||||
severity?: NotificationSeverity;
|
||||
}
|
||||
|
||||
export type AuditAction = "create" | "update" | "delete";
|
||||
|
||||
Reference in New Issue
Block a user