cdb2a15643
Lab side reported that after accepting a job / advancing a step the
button kept its 'Yükleniyor' state and the page didn't reflect the new
status until they hit refresh. Two issues stacked on top of each other:
1. The button forms were passing the action through an extra
startTransition wrap — 'action={(fd) => startTransition(() => action(fd))}'.
With React 19 + useActionState this is unnecessary; useActionState
already manages its own transition. The double transition can leave
the dispatch's pending flag wedged in some race orderings, which
matches what the user saw.
2. revalidatePath() on the server invalidates the RSC cache but does not
trigger client navigation. So even after the action returned, the
page kept rendering the stale Job snapshot — and since the buttons
are conditional on job.status, the now-stale 'pending' status meant
the button stayed visible.
Fix in JobActionsPanel and the four sibling components (connections
delete row, pending inbound, pending outbound, file row delete):
- Removed the startTransition wrap; forms point at 'action' directly.
- Added useRouter() and call router.refresh() in the same useEffect
branch where the success toast fires. This forces the Server
Component tree to re-fetch, picks up the new job.status, and the
actions panel rerenders into whatever button is next in the flow.
- Cleaned the now-unused useTransition imports.
Net effect: tap 'İşleme Al' → spinner appears, ~400ms later the toast
hits and the row updates in place to 'Sonraki Aşama' without any
manual refresh.
210 lines
6.3 KiB
TypeScript
210 lines
6.3 KiB
TypeScript
"use client";
|
||
|
||
import * as React from "react";
|
||
import { useActionState, useEffect, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { Loader2, Trash2 } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogClose,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import { deleteConnectionAction } from "@/lib/appwrite/connection-actions";
|
||
import { initialConnectionActionState } from "@/lib/appwrite/connection-types";
|
||
import type { ConnectionWithCounterpart } from "@/lib/appwrite/connection-queries";
|
||
import type { ClinicPricing, TenantKind } from "@/lib/appwrite/schema";
|
||
import { PROSTHETIC_TYPE_OPTIONS } from "@/lib/appwrite/prosthetic-types";
|
||
import { ClinicPricingDialog } from "./clinic-pricing-dialog";
|
||
|
||
const TYPE_LABELS = Object.fromEntries(
|
||
PROSTHETIC_TYPE_OPTIONS.map((o) => [o.value, o.label]),
|
||
) as Record<string, string>;
|
||
|
||
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
});
|
||
|
||
export function ConnectionsTable({
|
||
rows,
|
||
selfKind,
|
||
pricingByCounterpart = {},
|
||
defaultCurrency = "TRY",
|
||
}: {
|
||
rows: ConnectionWithCounterpart[];
|
||
selfKind: TenantKind | null;
|
||
pricingByCounterpart?: Record<string, ClinicPricing[]>;
|
||
defaultCurrency?: string;
|
||
}) {
|
||
if (rows.length === 0) {
|
||
return (
|
||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||
Henüz onaylanmış bağlantınız yok. Yukarıdan talep gönderebilir veya kodunuzu paylaşabilirsiniz.
|
||
</p>
|
||
);
|
||
}
|
||
|
||
const isLab = selfKind === "lab";
|
||
|
||
return (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Karşı taraf</TableHead>
|
||
<TableHead>Tür</TableHead>
|
||
<TableHead>Onay tarihi</TableHead>
|
||
<TableHead>{isLab ? "Fiyat kuralları" : "Sizin için kurallar"}</TableHead>
|
||
<TableHead className="text-right">İşlem</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{rows.map((r) => {
|
||
const counterpartId = isLab ? r.clinicTenantId : r.labTenantId;
|
||
const pricing = pricingByCounterpart[counterpartId] ?? [];
|
||
return (
|
||
<ApprovedRow
|
||
key={r.$id}
|
||
row={r}
|
||
isLab={isLab}
|
||
pricing={pricing}
|
||
defaultCurrency={defaultCurrency}
|
||
/>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
);
|
||
}
|
||
|
||
function ApprovedRow({
|
||
row,
|
||
isLab,
|
||
pricing,
|
||
defaultCurrency,
|
||
}: {
|
||
row: ConnectionWithCounterpart;
|
||
isLab: boolean;
|
||
pricing: ClinicPricing[];
|
||
defaultCurrency: string;
|
||
}) {
|
||
const [state, action, pending] = useActionState(
|
||
deleteConnectionAction,
|
||
initialConnectionActionState,
|
||
);
|
||
const router = useRouter();
|
||
const [open, setOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (state.ok) {
|
||
toast.success("Bağlantı silindi.");
|
||
setOpen(false);
|
||
router.refresh();
|
||
} else if (state.error) {
|
||
toast.error(state.error);
|
||
}
|
||
}, [state, router]);
|
||
|
||
const kindLabel =
|
||
row.counterpart?.kind === "lab"
|
||
? "Laboratuvar"
|
||
: row.counterpart?.kind === "clinic"
|
||
? "Klinik"
|
||
: "—";
|
||
|
||
const counterpartId = isLab ? row.clinicTenantId : row.labTenantId;
|
||
|
||
return (
|
||
<TableRow>
|
||
<TableCell className="font-medium">{row.counterpart?.companyName ?? "—"}</TableCell>
|
||
<TableCell>
|
||
<Badge variant="secondary">{kindLabel}</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-muted-foreground">
|
||
{row.approvedAt ? dateFormatter.format(new Date(row.approvedAt)) : "—"}
|
||
</TableCell>
|
||
<TableCell className="text-muted-foreground text-xs">
|
||
{pricing.length === 0 ? (
|
||
<span>Katalog fiyatı</span>
|
||
) : (
|
||
<span>
|
||
{pricing.slice(0, 3).map((p, idx) => (
|
||
<React.Fragment key={p.$id}>
|
||
{idx > 0 && ", "}
|
||
<span className="text-foreground">
|
||
{TYPE_LABELS[p.prostheticType] ?? p.prostheticType}
|
||
</span>
|
||
{": "}
|
||
{p.customUnitPrice !== undefined && p.customUnitPrice !== null
|
||
? `${p.customUnitPrice} ${p.currency || defaultCurrency}`
|
||
: p.discountPercent
|
||
? `%${p.discountPercent} indirim`
|
||
: "—"}
|
||
</React.Fragment>
|
||
))}
|
||
{pricing.length > 3 && `, +${pricing.length - 3} kural`}
|
||
</span>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<div className="flex justify-end gap-2">
|
||
{isLab && (
|
||
<ClinicPricingDialog
|
||
clinicTenantId={counterpartId}
|
||
clinicName={row.counterpart?.companyName ?? "Klinik"}
|
||
rows={pricing}
|
||
defaultCurrency={defaultCurrency}
|
||
/>
|
||
)}
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<DialogTrigger asChild>
|
||
<Button size="sm" variant="outline">
|
||
<Trash2 className="size-4" />
|
||
Sil
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Bağlantı silinsin mi?</DialogTitle>
|
||
<DialogDescription>
|
||
{row.counterpart?.companyName ?? "Karşı taraf"} ile bağlantınız sonlandırılacak.
|
||
Mevcut işleriniz etkilenmez ancak yeni iş gönderemezsiniz.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<DialogClose asChild>
|
||
<Button type="button" variant="outline">Vazgeç</Button>
|
||
</DialogClose>
|
||
<form action={action}>
|
||
<input type="hidden" name="connectionId" value={row.$id} />
|
||
<Button type="submit" disabled={pending} variant="destructive">
|
||
{pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
||
Sil
|
||
</Button>
|
||
</form>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
}
|