feat(admin): collapse row actions into ⋯ dropdown menu
Replace per-row inline action buttons (Edit, Activate/Deactivate, Delete, Grant SuperUser) across all six admin tables with a Radix DropdownMenu triggered by a ⋯ button. Introduces RowActionsMenu/Item/DestructiveItem/ Separator primitives and a DeleteConfirmDialog modal. Each Edit*Button gains controlled open/onOpenChange props so the dialog can be driven from the table's per-row ActionsMenu sub-component. Toggle and delete actions use useTransition + router.refresh() directly in the table. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9758dcd8ab
commit
d27ec9152c
13 changed files with 564 additions and 171 deletions
|
|
@ -3,7 +3,7 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
import { createAccount, updateAccount, toggleAccountActive } from "./actions";
|
import { createAccount, updateAccount } from "./actions";
|
||||||
|
|
||||||
type AccountRow = {
|
type AccountRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -79,13 +79,24 @@ export function AddAccountButton({ suggestedCode }: { suggestedCode?: string })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditAccountButton({ account }: { account: AccountRow }) {
|
export function EditAccountButton({
|
||||||
|
account,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
account: AccountRow;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [toggling, setToggling] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const isControlled = controlledOpen !== undefined;
|
||||||
|
const open = isControlled ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPending(true);
|
setPending(true);
|
||||||
|
|
@ -97,25 +108,14 @@ export function EditAccountButton({ account }: { account: AccountRow }) {
|
||||||
else { setPending(false); setOpen(false); router.refresh(); }
|
else { setPending(false); setOpen(false); router.refresh(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggle() {
|
|
||||||
setToggling(true);
|
|
||||||
await toggleAccountActive(account.id);
|
|
||||||
router.refresh();
|
|
||||||
setToggling(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
{!isControlled && (
|
||||||
<button onClick={() => setOpen(true)}
|
<button onClick={() => setOpen(true)}
|
||||||
className="rounded border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100 transition-colors">
|
className="rounded border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100 transition-colors">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleToggle} disabled={toggling}
|
)}
|
||||||
className={`rounded border px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${account.isActive ? "border-danger-200 bg-danger-50 text-danger-700 hover:bg-danger-100" : "border-success-200 bg-success-50 text-success-700 hover:bg-success-100"}`}>
|
|
||||||
{toggling ? "…" : account.isActive ? "Deactivate" : "Activate"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<AdminDialog title="Edit Account" open={open} onClose={() => setOpen(false)}>
|
<AdminDialog title="Edit Account" open={open} onClose={() => setOpen(false)}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<AccountFormFields account={account} />
|
<AccountFormFields account={account} />
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useTableControls } from "@/components/ui/use-table-controls";
|
import { useTableControls } from "@/components/ui/use-table-controls";
|
||||||
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
||||||
import { AddAccountButton, EditAccountButton } from "./account-form";
|
import { AddAccountButton, EditAccountButton } from "./account-form";
|
||||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
||||||
import { deleteAccount } from "./actions";
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
|
import { deleteAccount, toggleAccountActive } from "./actions";
|
||||||
|
|
||||||
export type AccountRow = {
|
export type AccountRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -16,6 +19,51 @@ export type AccountRow = {
|
||||||
|
|
||||||
const CHIPS = ["Active", "Inactive"];
|
const CHIPS = ["Active", "Inactive"];
|
||||||
|
|
||||||
|
function AccountActionsMenu({ account }: { account: AccountRow }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function handleToggle() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await toggleAccountActive(account.id);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RowActionsMenu>
|
||||||
|
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||||
|
<RowActionsItem onClick={handleToggle} disabled={isPending}>
|
||||||
|
{account.isActive ? "Deactivate" : "Activate"}
|
||||||
|
</RowActionsItem>
|
||||||
|
<RowActionsSeparator />
|
||||||
|
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||||
|
</RowActionsMenu>
|
||||||
|
|
||||||
|
<EditAccountButton
|
||||||
|
account={{
|
||||||
|
id: account.id,
|
||||||
|
code: account.code,
|
||||||
|
name: account.name,
|
||||||
|
description: account.description,
|
||||||
|
isActive: account.isActive,
|
||||||
|
}}
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
/>
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
label={account.name}
|
||||||
|
onConfirm={() => deleteAccount(account.id)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AccountsTable({
|
export function AccountsTable({
|
||||||
accounts,
|
accounts,
|
||||||
suggestedCode,
|
suggestedCode,
|
||||||
|
|
@ -66,7 +114,7 @@ export function AccountsTable({
|
||||||
<SortableTh sortKey="name" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof AccountRow)}>Name</SortableTh>
|
<SortableTh sortKey="name" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof AccountRow)}>Name</SortableTh>
|
||||||
<SortableTh sortKey="description" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof AccountRow)}>Description</SortableTh>
|
<SortableTh sortKey="description" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof AccountRow)}>Description</SortableTh>
|
||||||
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof AccountRow)}>Status</SortableTh>
|
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof AccountRow)}>Status</SortableTh>
|
||||||
<th className="px-4 py-3"></th>
|
<th className="px-4 py-3 w-10"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-neutral-100">
|
<tbody className="divide-y divide-neutral-100">
|
||||||
|
|
@ -90,16 +138,7 @@ export function AccountsTable({
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="flex items-center gap-3">
|
<AccountActionsMenu account={account} />
|
||||||
<EditAccountButton account={{
|
|
||||||
id: account.id,
|
|
||||||
code: account.code,
|
|
||||||
name: account.name,
|
|
||||||
description: account.description,
|
|
||||||
isActive: account.isActive,
|
|
||||||
}} />
|
|
||||||
<ConfirmDeleteButton onDelete={deleteAccount.bind(null, account.id)} label={account.name} />
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { formatCurrency, formatDate } from "@/lib/utils";
|
import { formatCurrency, formatDate } from "@/lib/utils";
|
||||||
import { useTableControls } from "@/components/ui/use-table-controls";
|
import { useTableControls } from "@/components/ui/use-table-controls";
|
||||||
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
||||||
import { AddProductButton, ToggleProductButton } from "./product-form";
|
import { AddProductButton } from "./product-form";
|
||||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
||||||
import { deleteProduct } from "./actions";
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
|
import { deleteProduct, toggleProductActive } from "./actions";
|
||||||
|
|
||||||
export type ProductRow = {
|
export type ProductRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -22,6 +25,38 @@ export type ProductRow = {
|
||||||
|
|
||||||
const CHIPS = ["Active", "Inactive"];
|
const CHIPS = ["Active", "Inactive"];
|
||||||
|
|
||||||
|
function ProductActionsMenu({ product }: { product: ProductRow }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function handleToggle() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await toggleProductActive(product.id);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RowActionsMenu>
|
||||||
|
<RowActionsItem onClick={handleToggle} disabled={isPending}>
|
||||||
|
{product.isActive ? "Deactivate" : "Activate"}
|
||||||
|
</RowActionsItem>
|
||||||
|
<RowActionsSeparator />
|
||||||
|
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||||
|
</RowActionsMenu>
|
||||||
|
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
label={product.name}
|
||||||
|
onConfirm={() => deleteProduct(product.id)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ProductsTable({
|
export function ProductsTable({
|
||||||
products,
|
products,
|
||||||
canManage,
|
canManage,
|
||||||
|
|
@ -78,7 +113,7 @@ export function ProductsTable({
|
||||||
<SortableTh sortKey="lastVendorName" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof ProductRow)}>Last Vendor</SortableTh>
|
<SortableTh sortKey="lastVendorName" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof ProductRow)}>Last Vendor</SortableTh>
|
||||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Updated</th>
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Updated</th>
|
||||||
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof ProductRow)}>Status</SortableTh>
|
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof ProductRow)}>Status</SortableTh>
|
||||||
{canManage && <th className="px-4 py-3"></th>}
|
{canManage && <th className="px-4 py-3 w-10"></th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-neutral-100">
|
<tbody className="divide-y divide-neutral-100">
|
||||||
|
|
@ -126,16 +161,7 @@ export function ProductsTable({
|
||||||
</td>
|
</td>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="flex items-center gap-3">
|
<ProductActionsMenu product={product} />
|
||||||
<ToggleProductButton product={{
|
|
||||||
id: product.id,
|
|
||||||
code: product.code,
|
|
||||||
name: product.name,
|
|
||||||
description: product.description,
|
|
||||||
isActive: product.isActive,
|
|
||||||
}} />
|
|
||||||
<ConfirmDeleteButton onDelete={deleteProduct.bind(null, product.id)} label={product.name} />
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
import { createSite, updateSite, toggleSiteActive } from "./actions";
|
import { createSite, updateSite } from "./actions";
|
||||||
|
|
||||||
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
||||||
|
|
||||||
|
|
@ -74,12 +74,24 @@ export function AddSiteButton() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditSiteButton({ site }: { site: SiteRow }) {
|
export function EditSiteButton({
|
||||||
|
site,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
site: SiteRow;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const isControlled = controlledOpen !== undefined;
|
||||||
|
const open = isControlled ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault(); setPending(true); setError("");
|
e.preventDefault(); setPending(true); setError("");
|
||||||
const result = await updateSite(site.id, new FormData(e.currentTarget));
|
const result = await updateSite(site.id, new FormData(e.currentTarget));
|
||||||
|
|
@ -87,25 +99,18 @@ export function EditSiteButton({ site }: { site: SiteRow }) {
|
||||||
else { setPending(false); setOpen(false); router.refresh(); }
|
else { setPending(false); setOpen(false); router.refresh(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggle() {
|
|
||||||
await toggleSiteActive(site.id); router.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button onClick={() => setOpen(true)} className="text-xs text-primary-600 hover:underline font-medium">Edit</button>
|
{!isControlled && (
|
||||||
|
<button onClick={() => setOpen(true)} className="text-xs text-primary-600 hover:underline font-medium">Edit</button>
|
||||||
|
)}
|
||||||
<AdminDialog open={open} onClose={() => setOpen(false)} title={`Edit — ${site.name}`}>
|
<AdminDialog open={open} onClose={() => setOpen(false)} title={`Edit — ${site.name}`}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<SiteFormFields site={site} />
|
<SiteFormFields site={site} />
|
||||||
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex justify-end gap-3">
|
||||||
<button type="button" onClick={handleToggle} className={`text-xs underline ${site.isActive ? "text-danger-600" : "text-success-600"}`}>
|
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
|
||||||
{site.isActive ? "Deactivate" : "Activate"}
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Save"}</button>
|
||||||
</button>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
|
|
||||||
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Save"}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AdminDialog>
|
</AdminDialog>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useTableControls } from "@/components/ui/use-table-controls";
|
import { useTableControls } from "@/components/ui/use-table-controls";
|
||||||
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
||||||
import { AddSiteButton, EditSiteButton } from "./site-form";
|
import { AddSiteButton, EditSiteButton } from "./site-form";
|
||||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
||||||
import { deleteSite } from "./actions";
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
|
import { deleteSite, toggleSiteActive } from "./actions";
|
||||||
|
|
||||||
export type SiteRow = {
|
export type SiteRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -21,6 +24,53 @@ export type SiteRow = {
|
||||||
|
|
||||||
const CHIPS = ["Active", "Inactive"];
|
const CHIPS = ["Active", "Inactive"];
|
||||||
|
|
||||||
|
function SiteActionsMenu({ site }: { site: SiteRow }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function handleToggle() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await toggleSiteActive(site.id);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RowActionsMenu>
|
||||||
|
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||||
|
<RowActionsItem onClick={handleToggle} disabled={isPending}>
|
||||||
|
{site.isActive ? "Deactivate" : "Activate"}
|
||||||
|
</RowActionsItem>
|
||||||
|
<RowActionsSeparator />
|
||||||
|
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||||
|
</RowActionsMenu>
|
||||||
|
|
||||||
|
<EditSiteButton
|
||||||
|
site={{
|
||||||
|
id: site.id,
|
||||||
|
name: site.name,
|
||||||
|
code: site.code,
|
||||||
|
address: site.address,
|
||||||
|
latitude: site.latitude,
|
||||||
|
longitude: site.longitude,
|
||||||
|
isActive: site.isActive,
|
||||||
|
}}
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
/>
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
label={site.name}
|
||||||
|
onConfirm={() => deleteSite(site.id)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SitesTable({
|
export function SitesTable({
|
||||||
sites,
|
sites,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
|
@ -77,7 +127,7 @@ export function SitesTable({
|
||||||
<th className="px-4 py-3 text-right font-medium text-neutral-600">Items tracked</th>
|
<th className="px-4 py-3 text-right font-medium text-neutral-600">Items tracked</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Location</th>
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Location</th>
|
||||||
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Status</SortableTh>
|
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Status</SortableTh>
|
||||||
{canEdit && <th className="px-4 py-3"></th>}
|
{canEdit && <th className="px-4 py-3 w-10"></th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-neutral-100">
|
<tbody className="divide-y divide-neutral-100">
|
||||||
|
|
@ -119,18 +169,7 @@ export function SitesTable({
|
||||||
</td>
|
</td>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="flex items-center gap-3">
|
<SiteActionsMenu site={site} />
|
||||||
<EditSiteButton site={{
|
|
||||||
id: site.id,
|
|
||||||
name: site.name,
|
|
||||||
code: site.code,
|
|
||||||
address: site.address,
|
|
||||||
latitude: site.latitude,
|
|
||||||
longitude: site.longitude,
|
|
||||||
isActive: site.isActive,
|
|
||||||
}} />
|
|
||||||
<ConfirmDeleteButton onDelete={deleteSite.bind(null, site.id)} label={site.name} />
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
import { createUser, updateUser, toggleUserActive } from "./actions";
|
import { createUser, updateUser } from "./actions";
|
||||||
|
|
||||||
type UserRow = {
|
type UserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -112,13 +112,24 @@ export function AddUserButton() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditUserButton({ user }: { user: UserRow }) {
|
export function EditUserButton({
|
||||||
|
user,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
user: UserRow;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [toggling, setToggling] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const isControlled = controlledOpen !== undefined;
|
||||||
|
const open = isControlled ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPending(true);
|
setPending(true);
|
||||||
|
|
@ -130,25 +141,14 @@ export function EditUserButton({ user }: { user: UserRow }) {
|
||||||
else { setPending(false); setOpen(false); router.refresh(); }
|
else { setPending(false); setOpen(false); router.refresh(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggle() {
|
|
||||||
setToggling(true);
|
|
||||||
await toggleUserActive(user.id);
|
|
||||||
router.refresh();
|
|
||||||
setToggling(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
{!isControlled && (
|
||||||
<button onClick={() => setOpen(true)}
|
<button onClick={() => setOpen(true)}
|
||||||
className="rounded border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100 transition-colors">
|
className="rounded border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100 transition-colors">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleToggle} disabled={toggling}
|
)}
|
||||||
className={`rounded border px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${user.isActive ? "border-danger-200 bg-danger-50 text-danger-700 hover:bg-danger-100" : "border-success-200 bg-success-50 text-success-700 hover:bg-success-100"}`}>
|
|
||||||
{toggling ? "…" : user.isActive ? "Deactivate" : "Activate"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<AdminDialog title="Edit User" open={open} onClose={() => setOpen(false)}>
|
<AdminDialog title="Edit User" open={open} onClose={() => setOpen(false)}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<UserFormFields user={user} />
|
<UserFormFields user={user} />
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useTableControls } from "@/components/ui/use-table-controls";
|
import { useTableControls } from "@/components/ui/use-table-controls";
|
||||||
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
||||||
import { AddUserButton, EditUserButton } from "./user-form";
|
import { AddUserButton, EditUserButton } from "./user-form";
|
||||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
||||||
import { GrantSuperUserButton } from "./grant-superuser-button";
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
import { deleteUser } from "./actions";
|
import { deleteUser, toggleUserActive } from "./actions";
|
||||||
|
import { grantSuperUser } from "../superuser-requests/actions";
|
||||||
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
export type UserRow = {
|
export type UserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -29,6 +33,66 @@ const ROLE_LABELS: Record<string, string> = {
|
||||||
|
|
||||||
const CHIPS = ["Manning", "Technical", "Accounts", "Manager", "Superuser", "Auditor", "Admin", "Active", "Inactive"];
|
const CHIPS = ["Manning", "Technical", "Accounts", "Manager", "Superuser", "Auditor", "Admin", "Active", "Inactive"];
|
||||||
|
|
||||||
|
function UserActionsMenu({ user }: { user: UserRow }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [grantPending, startGrantTransition] = useTransition();
|
||||||
|
|
||||||
|
function handleToggle() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await toggleUserActive(user.id);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGrantSuperUser() {
|
||||||
|
startGrantTransition(async () => {
|
||||||
|
await grantSuperUser(user.id);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RowActionsMenu>
|
||||||
|
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||||
|
<RowActionsItem onClick={handleToggle} disabled={isPending}>
|
||||||
|
{user.isActive ? "Deactivate" : "Activate"}
|
||||||
|
</RowActionsItem>
|
||||||
|
{user.role !== "SUPERUSER" && user.role !== "ADMIN" && (
|
||||||
|
<RowActionsItem onClick={handleGrantSuperUser} disabled={grantPending}>
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5" />
|
||||||
|
Grant SuperUser
|
||||||
|
</RowActionsItem>
|
||||||
|
)}
|
||||||
|
<RowActionsSeparator />
|
||||||
|
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||||
|
</RowActionsMenu>
|
||||||
|
|
||||||
|
<EditUserButton
|
||||||
|
user={{
|
||||||
|
id: user.id,
|
||||||
|
employeeId: user.employeeId,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
isActive: user.isActive,
|
||||||
|
}}
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
/>
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
label={user.name}
|
||||||
|
onConfirm={() => deleteUser(user.id)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function UsersTable({ users }: { users: UserRow[] }) {
|
export function UsersTable({ users }: { users: UserRow[] }) {
|
||||||
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
||||||
useTableControls<UserRow>({
|
useTableControls<UserRow>({
|
||||||
|
|
@ -76,7 +140,7 @@ export function UsersTable({ users }: { users: UserRow[] }) {
|
||||||
<SortableTh sortKey="role" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof UserRow)}>Role</SortableTh>
|
<SortableTh sortKey="role" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof UserRow)}>Role</SortableTh>
|
||||||
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof UserRow)}>Status</SortableTh>
|
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof UserRow)}>Status</SortableTh>
|
||||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Created</th>
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Created</th>
|
||||||
<th className="px-4 py-3"></th>
|
<th className="px-4 py-3 w-10"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-neutral-100">
|
<tbody className="divide-y divide-neutral-100">
|
||||||
|
|
@ -108,20 +172,7 @@ export function UsersTable({ users }: { users: UserRow[] }) {
|
||||||
{new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric" }).format(new Date(user.createdAt))}
|
{new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric" }).format(new Date(user.createdAt))}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="flex items-center gap-3 flex-wrap">
|
<UserActionsMenu user={user} />
|
||||||
{user.role !== "SUPERUSER" && user.role !== "ADMIN" && (
|
|
||||||
<GrantSuperUserButton userId={user.id} />
|
|
||||||
)}
|
|
||||||
<EditUserButton user={{
|
|
||||||
id: user.id,
|
|
||||||
employeeId: user.employeeId,
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
role: user.role,
|
|
||||||
isActive: user.isActive,
|
|
||||||
}} />
|
|
||||||
<ConfirmDeleteButton onDelete={deleteUser.bind(null, user.id)} label={user.name} />
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
40
App/app/(portal)/admin/vendors/vendor-form.tsx
vendored
40
App/app/(portal)/admin/vendors/vendor-form.tsx
vendored
|
|
@ -280,12 +280,24 @@ export function AddVendorButton({ suggestedId }: { suggestedId?: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditVendorButton({ vendor }: { vendor: VendorRow }) {
|
export function EditVendorButton({
|
||||||
|
vendor,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
vendor: VendorRow;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const isControlled = controlledOpen !== undefined;
|
||||||
|
const open = isControlled ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault(); setPending(true); setError("");
|
e.preventDefault(); setPending(true); setError("");
|
||||||
const fd = new FormData(e.currentTarget);
|
const fd = new FormData(e.currentTarget);
|
||||||
|
|
@ -295,28 +307,22 @@ export function EditVendorButton({ vendor }: { vendor: VendorRow }) {
|
||||||
else { setPending(false); setOpen(false); router.refresh(); }
|
else { setPending(false); setOpen(false); router.refresh(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggle() { await toggleVendorActive(vendor.id); router.refresh(); }
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button onClick={() => setOpen(true)} className="rounded border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100 transition-colors">Edit</button>
|
{!isControlled && (
|
||||||
|
<button onClick={() => setOpen(true)} className="rounded border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100 transition-colors">Edit</button>
|
||||||
|
)}
|
||||||
<AdminDialog title={`Edit — ${vendor.name}`} open={open} onClose={() => setOpen(false)}>
|
<AdminDialog title={`Edit — ${vendor.name}`} open={open} onClose={() => setOpen(false)}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<VendorFormFields vendor={vendor} />
|
<VendorFormFields vendor={vendor} />
|
||||||
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex justify-end gap-3">
|
||||||
<button type="button" onClick={handleToggle}
|
<button type="button" onClick={() => setOpen(false)}
|
||||||
className={`rounded border px-2.5 py-1 text-xs font-medium transition-colors ${vendor.isActive ? "border-danger-200 bg-danger-50 text-danger-700 hover:bg-danger-100" : "border-success-200 bg-success-50 text-success-700 hover:bg-success-100"}`}>
|
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
|
||||||
{vendor.isActive ? "Deactivate" : "Activate"}
|
<button type="submit" disabled={pending}
|
||||||
|
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">
|
||||||
|
{pending ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex gap-3">
|
|
||||||
<button type="button" onClick={() => setOpen(false)}
|
|
||||||
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
|
|
||||||
<button type="submit" disabled={pending}
|
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">
|
|
||||||
{pending ? "Saving…" : "Save"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AdminDialog>
|
</AdminDialog>
|
||||||
|
|
|
||||||
71
App/app/(portal)/admin/vendors/vendors-table.tsx
vendored
71
App/app/(portal)/admin/vendors/vendors-table.tsx
vendored
|
|
@ -1,11 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useTableControls } from "@/components/ui/use-table-controls";
|
import { useTableControls } from "@/components/ui/use-table-controls";
|
||||||
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
||||||
import { AddVendorButton, EditVendorButton } from "./vendor-form";
|
import { AddVendorButton, EditVendorButton } from "./vendor-form";
|
||||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
||||||
import { deleteVendor } from "./actions";
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
|
import { deleteVendor, toggleVendorActive } from "./actions";
|
||||||
|
|
||||||
type ContactRow = {
|
type ContactRow = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -30,6 +33,54 @@ export type VendorRow = {
|
||||||
|
|
||||||
const CHIPS = ["Verified", "Unverified", "Active", "Inactive"];
|
const CHIPS = ["Verified", "Unverified", "Active", "Inactive"];
|
||||||
|
|
||||||
|
function VendorActionsMenu({ vendor }: { vendor: VendorRow }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function handleToggle() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await toggleVendorActive(vendor.id);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RowActionsMenu>
|
||||||
|
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||||
|
<RowActionsItem onClick={handleToggle} disabled={isPending}>
|
||||||
|
{vendor.isActive ? "Deactivate" : "Activate"}
|
||||||
|
</RowActionsItem>
|
||||||
|
<RowActionsSeparator />
|
||||||
|
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||||
|
</RowActionsMenu>
|
||||||
|
|
||||||
|
<EditVendorButton
|
||||||
|
vendor={{
|
||||||
|
id: vendor.id,
|
||||||
|
name: vendor.name,
|
||||||
|
vendorId: vendor.vendorId,
|
||||||
|
address: vendor.address,
|
||||||
|
pincode: vendor.pincode,
|
||||||
|
gstin: vendor.gstin,
|
||||||
|
isActive: vendor.isActive,
|
||||||
|
contacts: vendor.contacts,
|
||||||
|
}}
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
/>
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
label={vendor.name}
|
||||||
|
onConfirm={() => deleteVendor(vendor.id)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function VendorsTable({
|
export function VendorsTable({
|
||||||
vendors,
|
vendors,
|
||||||
suggestedVendorId,
|
suggestedVendorId,
|
||||||
|
|
@ -86,7 +137,7 @@ export function VendorsTable({
|
||||||
<th className="px-4 py-3 text-right font-medium text-neutral-600">Items</th>
|
<th className="px-4 py-3 text-right font-medium text-neutral-600">Items</th>
|
||||||
<SortableTh sortKey="isVerified" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VendorRow)}>Verified</SortableTh>
|
<SortableTh sortKey="isVerified" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VendorRow)}>Verified</SortableTh>
|
||||||
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VendorRow)}>Status</SortableTh>
|
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VendorRow)}>Status</SortableTh>
|
||||||
<th className="px-4 py-3"></th>
|
<th className="px-4 py-3 w-10"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-neutral-100">
|
<tbody className="divide-y divide-neutral-100">
|
||||||
|
|
@ -141,19 +192,7 @@ export function VendorsTable({
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="flex items-center gap-3">
|
<VendorActionsMenu vendor={vendor} />
|
||||||
<EditVendorButton vendor={{
|
|
||||||
id: vendor.id,
|
|
||||||
name: vendor.name,
|
|
||||||
vendorId: vendor.vendorId,
|
|
||||||
address: vendor.address,
|
|
||||||
pincode: vendor.pincode,
|
|
||||||
gstin: vendor.gstin,
|
|
||||||
isActive: vendor.isActive,
|
|
||||||
contacts: vendor.contacts,
|
|
||||||
}} />
|
|
||||||
<ConfirmDeleteButton onDelete={deleteVendor.bind(null, vendor.id)} label={vendor.name} />
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -71,13 +71,24 @@ export function AddVesselButton() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditVesselButton({ vessel }: { vessel: VesselRow }) {
|
export function EditVesselButton({
|
||||||
|
vessel,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
vessel: VesselRow;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [toggling, setToggling] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const isControlled = controlledOpen !== undefined;
|
||||||
|
const open = isControlled ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPending(true);
|
setPending(true);
|
||||||
|
|
@ -89,25 +100,14 @@ export function EditVesselButton({ vessel }: { vessel: VesselRow }) {
|
||||||
else { setPending(false); setOpen(false); router.refresh(); }
|
else { setPending(false); setOpen(false); router.refresh(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggle() {
|
|
||||||
setToggling(true);
|
|
||||||
await toggleVesselActive(vessel.id);
|
|
||||||
router.refresh();
|
|
||||||
setToggling(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
{!isControlled && (
|
||||||
<button onClick={() => setOpen(true)}
|
<button onClick={() => setOpen(true)}
|
||||||
className="rounded border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100 transition-colors">
|
className="rounded border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100 transition-colors">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleToggle} disabled={toggling}
|
)}
|
||||||
className={`rounded border px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${vessel.isActive ? "border-danger-200 bg-danger-50 text-danger-700 hover:bg-danger-100" : "border-success-200 bg-success-50 text-success-700 hover:bg-success-100"}`}>
|
|
||||||
{toggling ? "…" : vessel.isActive ? "Deactivate" : "Activate"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<AdminDialog title="Edit Cost Centre" open={open} onClose={() => setOpen(false)}>
|
<AdminDialog title="Edit Cost Centre" open={open} onClose={() => setOpen(false)}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<VesselFormFields vessel={vessel} />
|
<VesselFormFields vessel={vessel} />
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useTableControls } from "@/components/ui/use-table-controls";
|
import { useTableControls } from "@/components/ui/use-table-controls";
|
||||||
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
||||||
import { AddVesselButton, EditVesselButton } from "./vessel-form";
|
import { AddVesselButton, EditVesselButton } from "./vessel-form";
|
||||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
||||||
import { deleteVessel } from "./actions";
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
|
import { deleteVessel, toggleVesselActive } from "./actions";
|
||||||
|
|
||||||
export type VesselRow = {
|
export type VesselRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -16,6 +19,50 @@ export type VesselRow = {
|
||||||
|
|
||||||
const CHIPS = ["Active", "Inactive"];
|
const CHIPS = ["Active", "Inactive"];
|
||||||
|
|
||||||
|
function VesselActionsMenu({ vessel }: { vessel: VesselRow }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function handleToggle() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await toggleVesselActive(vessel.id);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RowActionsMenu>
|
||||||
|
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||||
|
<RowActionsItem onClick={handleToggle} disabled={isPending}>
|
||||||
|
{vessel.isActive ? "Deactivate" : "Activate"}
|
||||||
|
</RowActionsItem>
|
||||||
|
<RowActionsSeparator />
|
||||||
|
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||||
|
</RowActionsMenu>
|
||||||
|
|
||||||
|
<EditVesselButton
|
||||||
|
vessel={{
|
||||||
|
id: vessel.id,
|
||||||
|
name: vessel.name,
|
||||||
|
code: vessel.code,
|
||||||
|
isActive: vessel.isActive,
|
||||||
|
}}
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
/>
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
label={vessel.name}
|
||||||
|
onConfirm={() => deleteVessel(vessel.id)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function VesselsTable({ vessels }: { vessels: VesselRow[] }) {
|
export function VesselsTable({ vessels }: { vessels: VesselRow[] }) {
|
||||||
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
||||||
useTableControls<VesselRow>({
|
useTableControls<VesselRow>({
|
||||||
|
|
@ -61,7 +108,7 @@ export function VesselsTable({ vessels }: { vessels: VesselRow[] }) {
|
||||||
<SortableTh sortKey="name" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VesselRow)}>Name</SortableTh>
|
<SortableTh sortKey="name" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VesselRow)}>Name</SortableTh>
|
||||||
<SortableTh sortKey="siteName" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VesselRow)}>Site</SortableTh>
|
<SortableTh sortKey="siteName" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VesselRow)}>Site</SortableTh>
|
||||||
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VesselRow)}>Status</SortableTh>
|
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VesselRow)}>Status</SortableTh>
|
||||||
<th className="px-4 py-3"></th>
|
<th className="px-4 py-3 w-10"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-neutral-100">
|
<tbody className="divide-y divide-neutral-100">
|
||||||
|
|
@ -87,15 +134,7 @@ export function VesselsTable({ vessels }: { vessels: VesselRow[] }) {
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="flex items-center gap-3">
|
<VesselActionsMenu vessel={vessel} />
|
||||||
<EditVesselButton vessel={{
|
|
||||||
id: vessel.id,
|
|
||||||
name: vessel.name,
|
|
||||||
code: vessel.code,
|
|
||||||
isActive: vessel.isActive,
|
|
||||||
}} />
|
|
||||||
<ConfirmDeleteButton onDelete={deleteVessel.bind(null, vessel.id)} label={vessel.name} />
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
71
App/components/ui/delete-confirm-dialog.tsx
Normal file
71
App/components/ui/delete-confirm-dialog.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
|
||||||
|
type ActionResult = { ok: true } | { error: string };
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
label: string;
|
||||||
|
onConfirm: () => Promise<ActionResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteConfirmDialog({ open, onOpenChange, label, onConfirm }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
if (isPending) return;
|
||||||
|
setError("");
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
setError("");
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await onConfirm();
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
} else {
|
||||||
|
onOpenChange(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminDialog title={`Delete ${label}?`} open={open} onClose={handleClose}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
This action cannot be undone. Are you sure you want to permanently delete{" "}
|
||||||
|
<span className="font-semibold text-neutral-900">{label}</span>?
|
||||||
|
</p>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isPending}
|
||||||
|
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={isPending}
|
||||||
|
className="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white hover:bg-danger-700 disabled:opacity-60 transition-colors"
|
||||||
|
>
|
||||||
|
{isPending ? "Deleting…" : "Delete"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdminDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
App/components/ui/row-actions-menu.tsx
Normal file
78
App/components/ui/row-actions-menu.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { MoreHorizontal } from "lucide-react";
|
||||||
|
|
||||||
|
export function RowActionsMenu({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger asChild>
|
||||||
|
<button
|
||||||
|
className="rounded p-1.5 text-neutral-400 hover:text-neutral-600 hover:bg-neutral-100 transition-colors"
|
||||||
|
aria-label="Row actions"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
align="end"
|
||||||
|
sideOffset={4}
|
||||||
|
className="min-w-[160px] rounded-lg border border-neutral-200 bg-white shadow-lg py-1 z-50"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RowActionsItem({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick?.();
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-neutral-50 transition-colors text-neutral-700 outline-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RowActionsDestructiveItem({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick?.();
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-danger-50 transition-colors text-danger-600 outline-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RowActionsSeparator() {
|
||||||
|
return <DropdownMenu.Separator className="my-1 h-px bg-neutral-100" />;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue