feat(admin): user, vendor, vessel and account management

Users: CRUD with role assignment, bcrypt password, cannot deactivate own account.
Vendors: CRUD with address, GSTIN, contact mobile; isVerified set when vendorId provided.
Vessels: CRUD with IMO number uniqueness check.
Accounts: CRUD with unique account code.
This commit is contained in:
Hardik 2026-05-06 00:15:41 +05:30
parent 31906ec8bb
commit 446c226c77
12 changed files with 1282 additions and 0 deletions

View file

@ -0,0 +1,137 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { createAccount, updateAccount, toggleAccountActive } from "./actions";
type AccountRow = {
id: string;
code: string;
name: string;
description: string | null;
isActive: boolean;
};
function AccountFormFields({ account }: { account?: AccountRow }) {
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Account Code *</label>
<input name="code" defaultValue={account?.code} required
className="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"
placeholder="e.g. OPS-001" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Account Name *</label>
<input name="name" defaultValue={account?.name} required
className="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" />
</div>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Description</label>
<input name="description" defaultValue={account?.description ?? ""}
className="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" />
</div>
</div>
);
}
export function AddAccountButton() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setPending(true);
setError("");
const result = await createAccount(new FormData(e.currentTarget));
if ("error" in result) { setError(result.error); setPending(false); }
else { setOpen(false); router.refresh(); }
}
return (
<>
<button onClick={() => setOpen(true)}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
+ Add Account
</button>
<AdminDialog title="Add Account" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<AccountFormFields />
{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={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
Cancel
</button>
<button type="submit" disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Creating…" : "Create Account"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}
export function EditAccountButton({ account }: { account: AccountRow }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [toggling, setToggling] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setPending(true);
setError("");
const fd = new FormData(e.currentTarget);
fd.set("id", account.id);
const result = await updateAccount(fd);
if ("error" in result) { setError(result.error); setPending(false); }
else { setOpen(false); router.refresh(); }
}
async function handleToggle() {
setToggling(true);
await toggleAccountActive(account.id);
router.refresh();
setToggling(false);
}
return (
<>
<div className="flex items-center gap-3">
<button onClick={() => setOpen(true)}
className="text-sm text-primary-600 hover:text-primary-700 font-medium">
Edit
</button>
<button onClick={handleToggle} disabled={toggling}
className={`text-sm font-medium ${account.isActive ? "text-danger-600 hover:text-danger-700" : "text-success-600 hover:text-success-700"}`}>
{toggling ? "…" : account.isActive ? "Deactivate" : "Activate"}
</button>
</div>
<AdminDialog title="Edit Account" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<AccountFormFields account={account} />
{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={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
Cancel
</button>
<button type="submit" disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Saving…" : "Save Changes"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}

View file

@ -0,0 +1,76 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { z } from "zod";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string };
const accountSchema = z.object({
code: z.string().min(1, "Account code is required"),
name: z.string().min(1, "Account name is required"),
description: z.string().optional(),
});
export async function createAccount(formData: FormData): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
return { error: "Unauthorized" };
}
const parsed = accountSchema.safeParse({
code: formData.get("code"),
name: formData.get("name"),
description: formData.get("description") || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const data = parsed.data;
const exists = await db.account.findUnique({ where: { code: data.code } });
if (exists) return { error: "An account with that code already exists" };
await db.account.create({ data: { code: data.code, name: data.name, description: data.description ?? null } });
revalidatePath("/admin/accounts");
return { ok: true };
}
export async function updateAccount(formData: FormData): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
return { error: "Unauthorized" };
}
const id = formData.get("id") as string;
if (!id) return { error: "Account ID is required" };
const parsed = accountSchema.safeParse({
code: formData.get("code"),
name: formData.get("name"),
description: formData.get("description") || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const data = parsed.data;
const conflict = await db.account.findFirst({ where: { code: data.code, id: { not: id } } });
if (conflict) return { error: "Another account already uses that code" };
await db.account.update({ where: { id }, data: { code: data.code, name: data.name, description: data.description ?? null } });
revalidatePath("/admin/accounts");
return { ok: true };
}
export async function toggleAccountActive(accountId: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
return { error: "Unauthorized" };
}
const account = await db.account.findUnique({ where: { id: accountId }, select: { isActive: true } });
if (!account) return { error: "Account not found" };
await db.account.update({ where: { id: accountId }, data: { isActive: !account.isActive } });
revalidatePath("/admin/accounts");
return { ok: true };
}

View file

@ -0,0 +1,70 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation";
import { AddAccountButton, EditAccountButton } from "./account-form";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Account Management" };
export default async function AdminAccountsPage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard");
const accounts = await db.account.findMany({ orderBy: { code: "asc" } });
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-neutral-900">Account / Cost Centre Management</h1>
<AddAccountButton />
</div>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Code</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Name</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Description</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{accounts.map((account) => (
<tr key={account.id} className="hover:bg-neutral-50">
<td className="px-4 py-3 font-mono text-xs font-medium text-neutral-700">{account.code}</td>
<td className="px-4 py-3 font-medium text-neutral-900">{account.name}</td>
<td className="px-4 py-3 text-neutral-500 text-xs">{account.description ?? "—"}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
account.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
}`}>
{account.isActive ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3">
<EditAccountButton account={{
id: account.id,
code: account.code,
name: account.name,
description: account.description,
isActive: account.isActive,
}} />
</td>
</tr>
))}
{accounts.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-neutral-400">No accounts yet.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,116 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { z } from "zod";
import bcrypt from "bcryptjs";
import { revalidatePath } from "next/cache";
import type { Role } from "@prisma/client";
type ActionResult = { ok: true } | { error: string };
const userSchema = z.object({
employeeId: z.string().min(1, "Employee ID is required"),
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
role: z.enum(["TECHNICAL", "MANNING", "ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"]),
password: z.string().min(8, "Password must be at least 8 characters").optional(),
});
export async function createUser(formData: FormData): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_users")) {
return { error: "Unauthorized" };
}
const parsed = userSchema.safeParse({
employeeId: formData.get("employeeId"),
name: formData.get("name"),
email: formData.get("email"),
role: formData.get("role"),
password: formData.get("password") || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const data = parsed.data;
if (!data.password) return { error: "Password is required for new users" };
const exists = await db.user.findFirst({
where: { OR: [{ email: data.email }, { employeeId: data.employeeId }] },
});
if (exists) return { error: "A user with that email or employee ID already exists" };
const passwordHash = await bcrypt.hash(data.password, 12);
await db.user.create({
data: {
employeeId: data.employeeId,
name: data.name,
email: data.email,
role: data.role as Role,
passwordHash,
},
});
revalidatePath("/admin/users");
return { ok: true };
}
export async function updateUser(formData: FormData): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_users")) {
return { error: "Unauthorized" };
}
const id = formData.get("id") as string;
if (!id) return { error: "User ID is required" };
const parsed = userSchema.safeParse({
employeeId: formData.get("employeeId"),
name: formData.get("name"),
email: formData.get("email"),
role: formData.get("role"),
password: formData.get("password") || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const data = parsed.data;
const conflict = await db.user.findFirst({
where: {
AND: [
{ id: { not: id } },
{ OR: [{ email: data.email }, { employeeId: data.employeeId }] },
],
},
});
if (conflict) return { error: "Another user already has that email or employee ID" };
const updateData: Parameters<typeof db.user.update>[0]["data"] = {
employeeId: data.employeeId,
name: data.name,
email: data.email,
role: data.role as Role,
};
if (data.password) {
updateData.passwordHash = await bcrypt.hash(data.password, 12);
}
await db.user.update({ where: { id }, data: updateData });
revalidatePath("/admin/users");
return { ok: true };
}
export async function toggleUserActive(userId: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_users")) {
return { error: "Unauthorized" };
}
if (userId === session.user.id) return { error: "You cannot deactivate your own account" };
const user = await db.user.findUnique({ where: { id: userId }, select: { isActive: true } });
if (!user) return { error: "User not found" };
await db.user.update({ where: { id: userId }, data: { isActive: !user.isActive } });
revalidatePath("/admin/users");
return { ok: true };
}

View file

@ -0,0 +1,85 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation";
import { formatDate } from "@/lib/utils";
import { AddUserButton, EditUserButton } from "./user-form";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "User Management" };
const ROLE_LABELS: Record<string, string> = {
TECHNICAL: "Technical",
MANNING: "Manning",
ACCOUNTS: "Accounts",
MANAGER: "Manager",
SUPERUSER: "SuperUser",
AUDITOR: "Auditor",
ADMIN: "Admin",
};
export default async function AdminUsersPage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_users")) redirect("/dashboard");
const users = await db.user.findMany({ orderBy: { createdAt: "desc" } });
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-neutral-900">User Management</h1>
<AddUserButton />
</div>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Employee ID</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Name</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Email</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Role</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Created</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{users.map((user) => (
<tr key={user.id} className="hover:bg-neutral-50">
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{user.employeeId}</td>
<td className="px-4 py-3 font-medium text-neutral-900">{user.name}</td>
<td className="px-4 py-3 text-neutral-600">{user.email}</td>
<td className="px-4 py-3">
<span className="rounded-full bg-neutral-100 px-2.5 py-0.5 text-xs font-medium text-neutral-700">
{ROLE_LABELS[user.role] ?? user.role}
</span>
</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
user.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
}`}>
{user.isActive ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
<td className="px-4 py-3">
<EditUserButton user={{
id: user.id,
employeeId: user.employeeId,
name: user.name,
email: user.email,
role: user.role,
isActive: user.isActive,
}} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,162 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { createUser, updateUser, toggleUserActive } from "./actions";
type UserRow = {
id: string;
employeeId: string;
name: string;
email: string;
role: string;
isActive: boolean;
};
const ROLES = [
{ value: "TECHNICAL", label: "Technical" },
{ value: "MANNING", label: "Manning" },
{ value: "ACCOUNTS", label: "Accounts" },
{ value: "MANAGER", label: "Manager" },
{ value: "SUPERUSER", label: "SuperUser" },
{ value: "AUDITOR", label: "Auditor" },
{ value: "ADMIN", label: "Admin" },
];
function UserFormFields({ user }: { user?: UserRow }) {
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Employee ID *</label>
<input name="employeeId" defaultValue={user?.employeeId} required
className="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" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Role *</label>
<select name="role" defaultValue={user?.role ?? "TECHNICAL"} required
className="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">
{ROLES.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Full Name *</label>
<input name="name" defaultValue={user?.name} required
className="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" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Email *</label>
<input name="email" type="email" defaultValue={user?.email} required
className="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" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">
{user ? "New Password (leave blank to keep current)" : "Password *"}
</label>
<input name="password" type="password" minLength={8}
required={!user}
className="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" />
</div>
</div>
);
}
export function AddUserButton() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setPending(true);
setError("");
const result = await createUser(new FormData(e.currentTarget));
if ("error" in result) { setError(result.error); setPending(false); }
else { setOpen(false); router.refresh(); }
}
return (
<>
<button onClick={() => setOpen(true)}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
+ Add User
</button>
<AdminDialog title="Add User" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<UserFormFields />
{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={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
Cancel
</button>
<button type="submit" disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Creating…" : "Create User"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}
export function EditUserButton({ user }: { user: UserRow }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [toggling, setToggling] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setPending(true);
setError("");
const fd = new FormData(e.currentTarget);
fd.set("id", user.id);
const result = await updateUser(fd);
if ("error" in result) { setError(result.error); setPending(false); }
else { setOpen(false); router.refresh(); }
}
async function handleToggle() {
setToggling(true);
await toggleUserActive(user.id);
router.refresh();
setToggling(false);
}
return (
<>
<div className="flex items-center gap-3">
<button onClick={() => setOpen(true)}
className="text-sm text-primary-600 hover:text-primary-700 font-medium">
Edit
</button>
<button onClick={handleToggle} disabled={toggling}
className={`text-sm font-medium ${user.isActive ? "text-danger-600 hover:text-danger-700" : "text-success-600 hover:text-success-700"}`}>
{toggling ? "…" : user.isActive ? "Deactivate" : "Activate"}
</button>
</div>
<AdminDialog title="Edit User" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<UserFormFields user={user} />
{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={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
Cancel
</button>
<button type="submit" disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Saving…" : "Save Changes"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}

View file

@ -0,0 +1,119 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { z } from "zod";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string };
const vendorSchema = z.object({
name: z.string().min(1, "Vendor name is required"),
vendorId: z.string().optional(),
address: z.string().optional(),
gstin: z.string().optional(),
contactName: z.string().optional(),
contactMobile: z.string().optional(),
contactEmail: z.string().email("Invalid contact email").optional().or(z.literal("")),
});
export async function createVendor(formData: FormData): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vendors")) {
return { error: "Unauthorized" };
}
const parsed = vendorSchema.safeParse({
name: formData.get("name"),
vendorId: formData.get("vendorId") || undefined,
address: formData.get("address") || undefined,
gstin: formData.get("gstin") || undefined,
contactName: formData.get("contactName") || undefined,
contactMobile: formData.get("contactMobile") || undefined,
contactEmail: formData.get("contactEmail") || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const data = parsed.data;
if (data.vendorId) {
const exists = await db.vendor.findUnique({ where: { vendorId: data.vendorId } });
if (exists) return { error: "A vendor with that Vendor ID already exists" };
}
await db.vendor.create({
data: {
name: data.name,
vendorId: data.vendorId ?? null,
address: data.address ?? null,
gstin: data.gstin ?? null,
contactName: data.contactName ?? null,
contactMobile: data.contactMobile ?? null,
contactEmail: data.contactEmail || null,
isVerified: !!data.vendorId,
},
});
revalidatePath("/admin/vendors");
return { ok: true };
}
export async function updateVendor(formData: FormData): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vendors")) {
return { error: "Unauthorized" };
}
const id = formData.get("id") as string;
if (!id) return { error: "Vendor ID is required" };
const parsed = vendorSchema.safeParse({
name: formData.get("name"),
vendorId: formData.get("vendorId") || undefined,
address: formData.get("address") || undefined,
gstin: formData.get("gstin") || undefined,
contactName: formData.get("contactName") || undefined,
contactMobile: formData.get("contactMobile") || undefined,
contactEmail: formData.get("contactEmail") || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const data = parsed.data;
if (data.vendorId) {
const conflict = await db.vendor.findFirst({
where: { vendorId: data.vendorId, id: { not: id } },
});
if (conflict) return { error: "Another vendor already has that Vendor ID" };
}
await db.vendor.update({
where: { id },
data: {
name: data.name,
vendorId: data.vendorId ?? null,
address: data.address ?? null,
gstin: data.gstin ?? null,
contactName: data.contactName ?? null,
contactMobile: data.contactMobile ?? null,
contactEmail: data.contactEmail || null,
isVerified: !!data.vendorId,
},
});
revalidatePath("/admin/vendors");
return { ok: true };
}
export async function toggleVendorActive(vendorId: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vendors")) {
return { error: "Unauthorized" };
}
const vendor = await db.vendor.findUnique({ where: { id: vendorId }, select: { isActive: true } });
if (!vendor) return { error: "Vendor not found" };
await db.vendor.update({ where: { id: vendorId }, data: { isActive: !vendor.isActive } });
revalidatePath("/admin/vendors");
return { ok: true };
}

View file

@ -0,0 +1,84 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation";
import { AddVendorButton, EditVendorButton } from "./vendor-form";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Vendor Management" };
export default async function AdminVendorsPage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_vendors")) redirect("/dashboard");
const vendors = await db.vendor.findMany({ orderBy: { name: "asc" } });
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-neutral-900">Vendor Registry</h1>
<AddVendorButton />
</div>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Vendor ID</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Name</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Contact</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Verified</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{vendors.map((vendor) => (
<tr key={vendor.id} className="hover:bg-neutral-50">
<td className="px-4 py-3 font-mono text-xs text-neutral-600">
{vendor.vendorId ?? <span className="text-warning-700 italic">Pending</span>}
</td>
<td className="px-4 py-3 font-medium text-neutral-900">{vendor.name}</td>
<td className="px-4 py-3 text-neutral-600">
{vendor.contactName ?? "—"}
{vendor.contactEmail && (
<span className="block text-xs text-neutral-400">{vendor.contactEmail}</span>
)}
</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
vendor.isVerified ? "bg-success-100 text-success-700" : "bg-warning-100 text-warning-700"
}`}>
{vendor.isVerified ? "Verified" : "Unverified"}
</span>
</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
vendor.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
}`}>
{vendor.isActive ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3">
<EditVendorButton vendor={{
id: vendor.id,
name: vendor.name,
vendorId: vendor.vendorId,
address: (vendor as typeof vendor & { address?: string | null }).address ?? null,
gstin: (vendor as typeof vendor & { gstin?: string | null }).gstin ?? null,
contactName: vendor.contactName,
contactMobile: (vendor as typeof vendor & { contactMobile?: string | null }).contactMobile ?? null,
contactEmail: vendor.contactEmail,
isActive: vendor.isActive,
}} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { createVendor, updateVendor, toggleVendorActive } from "./actions";
type VendorRow = {
id: string;
name: string;
vendorId: string | null;
address: string | null;
gstin: string | null;
contactName: string | null;
contactMobile: string | null;
contactEmail: string | null;
isActive: boolean;
};
const INPUT_CLS =
"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";
function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="block text-xs font-medium text-neutral-700 mb-1">Vendor Name *</label>
<input name="name" defaultValue={vendor?.name} required className={INPUT_CLS} />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Vendor ID (leave blank if unverified)</label>
<input name="vendorId" defaultValue={vendor?.vendorId ?? ""} className={INPUT_CLS} placeholder="e.g. VND-0042" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">GSTIN</label>
<input name="gstin" defaultValue={vendor?.gstin ?? ""} className={INPUT_CLS} placeholder="e.g. 27AAACG1840M1ZL" />
</div>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Address</label>
<textarea name="address" defaultValue={vendor?.address ?? ""} rows={2} className={INPUT_CLS} placeholder="Full vendor address" />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Contact Name</label>
<input name="contactName" defaultValue={vendor?.contactName ?? ""} className={INPUT_CLS} />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Contact Mobile</label>
<input name="contactMobile" defaultValue={vendor?.contactMobile ?? ""} className={INPUT_CLS} placeholder="e.g. 9876543210" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Contact Email</label>
<input name="contactEmail" type="email" defaultValue={vendor?.contactEmail ?? ""} className={INPUT_CLS} />
</div>
</div>
</div>
);
}
export function AddVendorButton() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setPending(true);
setError("");
const result = await createVendor(new FormData(e.currentTarget));
if ("error" in result) { setError(result.error); setPending(false); }
else { setOpen(false); router.refresh(); }
}
return (
<>
<button onClick={() => setOpen(true)}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
+ Add Vendor
</button>
<AdminDialog title="Add Vendor" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<VendorFormFields />
{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={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
Cancel
</button>
<button type="submit" disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Creating…" : "Create Vendor"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}
export function EditVendorButton({ vendor }: { vendor: VendorRow }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [toggling, setToggling] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setPending(true);
setError("");
const fd = new FormData(e.currentTarget);
fd.set("id", vendor.id);
const result = await updateVendor(fd);
if ("error" in result) { setError(result.error); setPending(false); }
else { setOpen(false); router.refresh(); }
}
async function handleToggle() {
setToggling(true);
await toggleVendorActive(vendor.id);
router.refresh();
setToggling(false);
}
return (
<>
<div className="flex items-center gap-3">
<button onClick={() => setOpen(true)}
className="text-sm text-primary-600 hover:text-primary-700 font-medium">
Edit
</button>
<button onClick={handleToggle} disabled={toggling}
className={`text-sm font-medium ${vendor.isActive ? "text-danger-600 hover:text-danger-700" : "text-success-600 hover:text-success-700"}`}>
{toggling ? "…" : vendor.isActive ? "Deactivate" : "Activate"}
</button>
</div>
<AdminDialog title="Edit Vendor" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<VendorFormFields vendor={vendor} />
{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={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
Cancel
</button>
<button type="submit" disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Saving…" : "Save Changes"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}

View file

@ -0,0 +1,77 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { z } from "zod";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string };
const vesselSchema = z.object({
name: z.string().min(1, "Vessel name is required"),
imoNumber: z.string().optional(),
});
export async function createVessel(formData: FormData): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
return { error: "Unauthorized" };
}
const parsed = vesselSchema.safeParse({
name: formData.get("name"),
imoNumber: formData.get("imoNumber") || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const data = parsed.data;
if (data.imoNumber) {
const exists = await db.vessel.findUnique({ where: { imoNumber: data.imoNumber } });
if (exists) return { error: "A vessel with that IMO number already exists" };
}
await db.vessel.create({ data: { name: data.name, imoNumber: data.imoNumber ?? null } });
revalidatePath("/admin/vessels");
return { ok: true };
}
export async function updateVessel(formData: FormData): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
return { error: "Unauthorized" };
}
const id = formData.get("id") as string;
if (!id) return { error: "Vessel ID is required" };
const parsed = vesselSchema.safeParse({
name: formData.get("name"),
imoNumber: formData.get("imoNumber") || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const data = parsed.data;
if (data.imoNumber) {
const conflict = await db.vessel.findFirst({ where: { imoNumber: data.imoNumber, id: { not: id } } });
if (conflict) return { error: "Another vessel already has that IMO number" };
}
await db.vessel.update({ where: { id }, data: { name: data.name, imoNumber: data.imoNumber ?? null } });
revalidatePath("/admin/vessels");
return { ok: true };
}
export async function toggleVesselActive(vesselId: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
return { error: "Unauthorized" };
}
const vessel = await db.vessel.findUnique({ where: { id: vesselId }, select: { isActive: true } });
if (!vessel) return { error: "Vessel not found" };
await db.vessel.update({ where: { id: vesselId }, data: { isActive: !vessel.isActive } });
revalidatePath("/admin/vessels");
return { ok: true };
}

View file

@ -0,0 +1,69 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation";
import { AddVesselButton, EditVesselButton } from "./vessel-form";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Vessel Management" };
export default async function AdminVesselsPage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard");
const vessels = await db.vessel.findMany({ orderBy: { name: "asc" } });
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-neutral-900">Vessel Management</h1>
<AddVesselButton />
</div>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Name</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">IMO Number</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{vessels.map((vessel) => (
<tr key={vessel.id} className="hover:bg-neutral-50">
<td className="px-4 py-3 font-medium text-neutral-900">{vessel.name}</td>
<td className="px-4 py-3 font-mono text-xs text-neutral-600">
{vessel.imoNumber ?? <span className="text-neutral-400 italic"></span>}
</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
vessel.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
}`}>
{vessel.isActive ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3">
<EditVesselButton vessel={{
id: vessel.id,
name: vessel.name,
imoNumber: vessel.imoNumber,
isActive: vessel.isActive,
}} />
</td>
</tr>
))}
{vessels.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-neutral-400">No vessels yet.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,129 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { createVessel, updateVessel, toggleVesselActive } from "./actions";
type VesselRow = {
id: string;
name: string;
imoNumber: string | null;
isActive: boolean;
};
function VesselFormFields({ vessel }: { vessel?: VesselRow }) {
return (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel Name *</label>
<input name="name" defaultValue={vessel?.name} required
className="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" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">IMO Number (optional)</label>
<input name="imoNumber" defaultValue={vessel?.imoNumber ?? ""}
className="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"
placeholder="e.g. 1234567" />
</div>
</div>
);
}
export function AddVesselButton() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setPending(true);
setError("");
const result = await createVessel(new FormData(e.currentTarget));
if ("error" in result) { setError(result.error); setPending(false); }
else { setOpen(false); router.refresh(); }
}
return (
<>
<button onClick={() => setOpen(true)}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
+ Add Vessel
</button>
<AdminDialog title="Add Vessel" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<VesselFormFields />
{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={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
Cancel
</button>
<button type="submit" disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Creating…" : "Create Vessel"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}
export function EditVesselButton({ vessel }: { vessel: VesselRow }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [toggling, setToggling] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setPending(true);
setError("");
const fd = new FormData(e.currentTarget);
fd.set("id", vessel.id);
const result = await updateVessel(fd);
if ("error" in result) { setError(result.error); setPending(false); }
else { setOpen(false); router.refresh(); }
}
async function handleToggle() {
setToggling(true);
await toggleVesselActive(vessel.id);
router.refresh();
setToggling(false);
}
return (
<>
<div className="flex items-center gap-3">
<button onClick={() => setOpen(true)}
className="text-sm text-primary-600 hover:text-primary-700 font-medium">
Edit
</button>
<button onClick={handleToggle} disabled={toggling}
className={`text-sm font-medium ${vessel.isActive ? "text-danger-600 hover:text-danger-700" : "text-success-600 hover:text-success-700"}`}>
{toggling ? "…" : vessel.isActive ? "Deactivate" : "Activate"}
</button>
</div>
<AdminDialog title="Edit Vessel" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<VesselFormFields vessel={vessel} />
{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={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
Cancel
</button>
<button type="submit" disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Saving…" : "Save Changes"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}