diff --git a/App/pelagia-portal/app/(portal)/admin/accounts/account-form.tsx b/App/pelagia-portal/app/(portal)/admin/accounts/account-form.tsx new file mode 100644 index 0000000..6ac8151 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/accounts/account-form.tsx @@ -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 ( +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ ); +} + +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) { + 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 ( + <> + + setOpen(false)}> +
+ + {error &&

{error}

} +
+ + +
+ +
+ + ); +} + +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) { + 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 ( + <> +
+ + +
+ setOpen(false)}> +
+ + {error &&

{error}

} +
+ + +
+ +
+ + ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/accounts/actions.ts b/App/pelagia-portal/app/(portal)/admin/accounts/actions.ts new file mode 100644 index 0000000..4a52dca --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/accounts/actions.ts @@ -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 { + 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 { + 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 { + 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 }; +} diff --git a/App/pelagia-portal/app/(portal)/admin/accounts/page.tsx b/App/pelagia-portal/app/(portal)/admin/accounts/page.tsx new file mode 100644 index 0000000..830d68b --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/accounts/page.tsx @@ -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 ( +
+
+

Account / Cost Centre Management

+ +
+ +
+ + + + + + + + + + + + {accounts.map((account) => ( + + + + + + + + ))} + {accounts.length === 0 && ( + + + + )} + +
CodeNameDescriptionStatus
{account.code}{account.name}{account.description ?? "—"} + + {account.isActive ? "Active" : "Inactive"} + + + +
No accounts yet.
+
+
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/users/actions.ts b/App/pelagia-portal/app/(portal)/admin/users/actions.ts new file mode 100644 index 0000000..c5e3808 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/users/actions.ts @@ -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 { + 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 { + 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[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 { + 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 }; +} diff --git a/App/pelagia-portal/app/(portal)/admin/users/page.tsx b/App/pelagia-portal/app/(portal)/admin/users/page.tsx new file mode 100644 index 0000000..5cdc8fe --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/users/page.tsx @@ -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 = { + 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 ( +
+
+

User Management

+ +
+ +
+ + + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + + + ))} + +
Employee IDNameEmailRoleStatusCreated
{user.employeeId}{user.name}{user.email} + + {ROLE_LABELS[user.role] ?? user.role} + + + + {user.isActive ? "Active" : "Inactive"} + + {formatDate(user.createdAt)} + +
+
+
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/users/user-form.tsx b/App/pelagia-portal/app/(portal)/admin/users/user-form.tsx new file mode 100644 index 0000000..4e4d5af --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/users/user-form.tsx @@ -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 ( +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ ); +} + +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) { + 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 ( + <> + + setOpen(false)}> +
+ + {error &&

{error}

} +
+ + +
+ +
+ + ); +} + +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) { + 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 ( + <> +
+ + +
+ setOpen(false)}> +
+ + {error &&

{error}

} +
+ + +
+ +
+ + ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/vendors/actions.ts b/App/pelagia-portal/app/(portal)/admin/vendors/actions.ts new file mode 100644 index 0000000..a5b1314 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/vendors/actions.ts @@ -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 { + 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 { + 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 { + 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 }; +} diff --git a/App/pelagia-portal/app/(portal)/admin/vendors/page.tsx b/App/pelagia-portal/app/(portal)/admin/vendors/page.tsx new file mode 100644 index 0000000..44ea03b --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/vendors/page.tsx @@ -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 ( +
+
+

Vendor Registry

+ +
+ +
+ + + + + + + + + + + + + {vendors.map((vendor) => ( + + + + + + + + + ))} + +
Vendor IDNameContactVerifiedStatus
+ {vendor.vendorId ?? Pending} + {vendor.name} + {vendor.contactName ?? "—"} + {vendor.contactEmail && ( + {vendor.contactEmail} + )} + + + {vendor.isVerified ? "Verified" : "Unverified"} + + + + {vendor.isActive ? "Active" : "Inactive"} + + + +
+
+
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/vendors/vendor-form.tsx b/App/pelagia-portal/app/(portal)/admin/vendors/vendor-form.tsx new file mode 100644 index 0000000..046d241 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/vendors/vendor-form.tsx @@ -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 ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +