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:
parent
31906ec8bb
commit
446c226c77
12 changed files with 1282 additions and 0 deletions
137
App/pelagia-portal/app/(portal)/admin/accounts/account-form.tsx
Normal file
137
App/pelagia-portal/app/(portal)/admin/accounts/account-form.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
76
App/pelagia-portal/app/(portal)/admin/accounts/actions.ts
Normal file
76
App/pelagia-portal/app/(portal)/admin/accounts/actions.ts
Normal 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 };
|
||||
}
|
||||
70
App/pelagia-portal/app/(portal)/admin/accounts/page.tsx
Normal file
70
App/pelagia-portal/app/(portal)/admin/accounts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
App/pelagia-portal/app/(portal)/admin/users/actions.ts
Normal file
116
App/pelagia-portal/app/(portal)/admin/users/actions.ts
Normal 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 };
|
||||
}
|
||||
85
App/pelagia-portal/app/(portal)/admin/users/page.tsx
Normal file
85
App/pelagia-portal/app/(portal)/admin/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
App/pelagia-portal/app/(portal)/admin/users/user-form.tsx
Normal file
162
App/pelagia-portal/app/(portal)/admin/users/user-form.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
119
App/pelagia-portal/app/(portal)/admin/vendors/actions.ts
vendored
Normal file
119
App/pelagia-portal/app/(portal)/admin/vendors/actions.ts
vendored
Normal 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 };
|
||||
}
|
||||
84
App/pelagia-portal/app/(portal)/admin/vendors/page.tsx
vendored
Normal file
84
App/pelagia-portal/app/(portal)/admin/vendors/page.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
158
App/pelagia-portal/app/(portal)/admin/vendors/vendor-form.tsx
vendored
Normal file
158
App/pelagia-portal/app/(portal)/admin/vendors/vendor-form.tsx
vendored
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
77
App/pelagia-portal/app/(portal)/admin/vessels/actions.ts
Normal file
77
App/pelagia-portal/app/(portal)/admin/vessels/actions.ts
Normal 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 };
|
||||
}
|
||||
69
App/pelagia-portal/app/(portal)/admin/vessels/page.tsx
Normal file
69
App/pelagia-portal/app/(portal)/admin/vessels/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
App/pelagia-portal/app/(portal)/admin/vessels/vessel-form.tsx
Normal file
129
App/pelagia-portal/app/(portal)/admin/vessels/vessel-form.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue