Compare commits
No commits in common. "master" and "fix/triage-owns-portal-routing" have entirely different histories.
master
...
fix/triage
22 changed files with 107 additions and 812 deletions
|
|
@ -31,13 +31,7 @@ jobs:
|
|||
pnpm build # includes prisma generate
|
||||
pnpm db:migrate:deploy
|
||||
|
||||
# NOT --update-env: this job runs inside the Forgejo Actions runner, whose
|
||||
# environment includes an ephemeral FORGEJO_TOKEN (the per-job token, revoked
|
||||
# when the job ends). --update-env would inject it into ppms, where it shadows
|
||||
# the real PAT from .env (Next.js does not override an already-set process.env
|
||||
# var) and breaks the Report Issue button once the job token expires. A plain
|
||||
# restart re-execs ppms from the pm2 daemon's clean env, so .env wins.
|
||||
pm2 restart ppms
|
||||
pm2 restart ppms --update-env
|
||||
echo "=== Deployed $TAG ==="
|
||||
|
||||
- name: Verify portal responds
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
name: Refresh staging
|
||||
|
||||
# Rebuilds the pms1 staging instance (pm2 `ppms-staging`, port 3200) to the latest
|
||||
# master on every merge to master, so staging always mirrors the trunk for
|
||||
# smoke-testing before a release tag. Also runnable on demand (workflow_dispatch).
|
||||
# See automation/README.md > "Staging".
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch: {}
|
||||
|
||||
# Only one staging refresh at a time; a newer master push cancels an in-flight build
|
||||
# (staging-up.sh always checks out the latest origin/master, so the newest wins).
|
||||
concurrency:
|
||||
group: refresh-staging
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
runs-on: host
|
||||
steps:
|
||||
- name: Rebuild staging on latest master
|
||||
run: |
|
||||
set -e
|
||||
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
|
||||
"$HOME/issue-watcher/staging-up.sh"
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { generateDownloadUrl } from "@/lib/storage";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { CompanyForm } from "../../company-form";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Edit Company" };
|
||||
|
||||
export default async function EditCompanyPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard");
|
||||
|
||||
const { id } = await params;
|
||||
const c = await db.company.findUnique({ where: { id } });
|
||||
if (!c) notFound();
|
||||
|
||||
return (
|
||||
<CompanyForm
|
||||
company={{
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
code: c.code,
|
||||
gstNumber: c.gstNumber,
|
||||
address: c.address,
|
||||
telephone: c.telephone,
|
||||
mobile: c.mobile,
|
||||
email: c.email,
|
||||
invoiceEmail: c.invoiceEmail,
|
||||
invoiceAddress: c.invoiceAddress,
|
||||
logoUrl: c.logoKey ? await generateDownloadUrl(c.logoKey) : null,
|
||||
stampUrl: c.stampKey ? await generateDownloadUrl(c.stampKey) : null,
|
||||
isActive: c.isActive,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,21 +3,11 @@
|
|||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { buildCompanyAssetKey, uploadBuffer } from "@/lib/storage";
|
||||
import { z } from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
type ActionResult = { ok: true } | { error: string };
|
||||
|
||||
// Branding assets (logo + stamp) shown on exported POs.
|
||||
const ASSET_MIME: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/webp": "webp",
|
||||
};
|
||||
const ASSET_MAX_BYTES = 4 * 1024 * 1024; // 4 MB — banners/seals can be larger than signatures
|
||||
|
||||
const companySchema = z.object({
|
||||
name: z.string().min(1, "Company name is required"),
|
||||
code: z.string().min(1, "Company code is required").max(10, "Code must be ≤ 10 characters").regex(/^[A-Z0-9]+$/i, "Code must be letters/numbers only").optional(),
|
||||
|
|
@ -30,7 +20,7 @@ const companySchema = z.object({
|
|||
invoiceAddress: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function createCompany(formData: FormData): Promise<{ ok: true; id: string } | { error: string }> {
|
||||
export async function createCompany(formData: FormData): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
||||
return { error: "Unauthorized" };
|
||||
|
|
@ -54,11 +44,11 @@ export async function createCompany(formData: FormData): Promise<{ ok: true; id:
|
|||
const conflict = await db.company.findFirst({ where: { code: { equals: code, mode: "insensitive" } } });
|
||||
if (conflict) return { error: `Code "${code.toUpperCase()}" is already used by another company.` };
|
||||
}
|
||||
const created = await db.company.create({
|
||||
await db.company.create({
|
||||
data: { name, code: code?.toUpperCase() ?? null, gstNumber: gstNumber ?? null, address: address ?? null, telephone: telephone ?? null, mobile: mobile ?? null, email: email || null, invoiceEmail: invoiceEmail || null, invoiceAddress: invoiceAddress ?? null },
|
||||
});
|
||||
revalidatePath("/admin/companies");
|
||||
return { ok: true, id: created.id };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateCompany(formData: FormData): Promise<ActionResult> {
|
||||
|
|
@ -108,58 +98,6 @@ export async function deleteCompany(id: string): Promise<ActionResult> {
|
|||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Branding assets (logo + stamp) ──────────────────────────────────────────────
|
||||
|
||||
export async function uploadCompanyAsset(formData: FormData): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
||||
return { error: "Unauthorized" };
|
||||
}
|
||||
|
||||
const companyId = formData.get("companyId") as string | null;
|
||||
const type = formData.get("type") as string | null;
|
||||
if (!companyId) return { error: "Company ID is required" };
|
||||
if (type !== "logo" && type !== "stamp") return { error: "Invalid asset type" };
|
||||
|
||||
const company = await db.company.findUnique({ where: { id: companyId }, select: { id: true } });
|
||||
if (!company) return { error: "Company not found" };
|
||||
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0) return { error: "No file provided" };
|
||||
if (file.size > ASSET_MAX_BYTES) return { error: "Image must be under 4 MB" };
|
||||
|
||||
const ext = ASSET_MIME[file.type];
|
||||
if (!ext) return { error: "Image must be a PNG, JPG, or WebP" };
|
||||
|
||||
const key = buildCompanyAssetKey(companyId, type, ext);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await uploadBuffer(key, buffer, file.type);
|
||||
|
||||
await db.company.update({
|
||||
where: { id: companyId },
|
||||
data: type === "logo" ? { logoKey: key } : { stampKey: key },
|
||||
});
|
||||
|
||||
revalidatePath("/admin/companies");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function removeCompanyAsset(companyId: string, type: "logo" | "stamp"): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
||||
return { error: "Unauthorized" };
|
||||
}
|
||||
if (type !== "logo" && type !== "stamp") return { error: "Invalid asset type" };
|
||||
|
||||
await db.company.update({
|
||||
where: { id: companyId },
|
||||
data: type === "logo" ? { logoKey: null } : { stampKey: null },
|
||||
});
|
||||
|
||||
revalidatePath("/admin/companies");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function toggleCompanyActive(id: string): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AddCompanyButton, EditCompanyButton } from "./company-form";
|
||||
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
||||
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
|
|
@ -23,20 +22,21 @@ export type CompanyRow = {
|
|||
};
|
||||
|
||||
function CompanyActionsMenu({ company }: { company: CompanyRow }) {
|
||||
const router = useRouter();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [toggleOpen, setToggleOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RowActionsMenu>
|
||||
<RowActionsItem onClick={() => router.push(`/admin/companies/${company.id}/edit`)}>Edit</RowActionsItem>
|
||||
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||
<RowActionsItem onClick={() => setToggleOpen(true)}>
|
||||
{company.isActive ? "Deactivate" : "Activate"}
|
||||
</RowActionsItem>
|
||||
<RowActionsSeparator />
|
||||
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||
</RowActionsMenu>
|
||||
<EditCompanyButton company={company} open={editOpen} onOpenChange={setEditOpen} />
|
||||
<DeleteConfirmDialog
|
||||
open={deleteOpen} onOpenChange={setDeleteOpen}
|
||||
label={company.name} onConfirm={() => deleteCompany(company.id)}
|
||||
|
|
@ -60,10 +60,7 @@ export function CompaniesTable({ companies }: { companies: CompanyRow[] }) {
|
|||
<h1 className="text-2xl font-semibold text-neutral-900">Company Management</h1>
|
||||
<p className="text-sm text-neutral-500 mt-0.5">Sister companies used for invoicing and purchase orders</p>
|
||||
</div>
|
||||
<Link href="/admin/companies/new"
|
||||
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
|
||||
+ Add Company
|
||||
</Link>
|
||||
<AddCompanyButton />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Upload, X } from "lucide-react";
|
||||
import { uploadCompanyAsset, removeCompanyAsset } from "./actions";
|
||||
|
||||
interface Props {
|
||||
companyId: string;
|
||||
type: "logo" | "stamp";
|
||||
label: string;
|
||||
hint: string;
|
||||
currentUrl: string | null;
|
||||
}
|
||||
|
||||
export function CompanyBrandingUploader({ companyId, type, label, hint, currentUrl }: Props) {
|
||||
const router = useRouter();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setError("");
|
||||
setPreview(URL.createObjectURL(file));
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
const file = inputRef.current?.files?.[0];
|
||||
if (!file) { setError("Please select a file first"); return; }
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("companyId", companyId);
|
||||
fd.append("type", type);
|
||||
fd.append("file", file);
|
||||
|
||||
setPending(true);
|
||||
setError("");
|
||||
const result = await uploadCompanyAsset(fd);
|
||||
setPending(false);
|
||||
|
||||
if ("error" in result) {
|
||||
setError(result.error);
|
||||
} else {
|
||||
setPreview(null);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
setRemoving(true);
|
||||
setError("");
|
||||
const result = await removeCompanyAsset(companyId, type);
|
||||
setRemoving(false);
|
||||
if ("error" in result) setError(result.error);
|
||||
else { setPreview(null); router.refresh(); }
|
||||
}
|
||||
|
||||
const displayUrl = preview ?? currentUrl;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-200 p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-neutral-700">{label}</p>
|
||||
{currentUrl && !preview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
disabled={removing}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-danger-700 hover:text-danger-800 disabled:opacity-50"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
{removing ? "Removing…" : "Remove"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{displayUrl && (
|
||||
<div className="rounded border border-neutral-200 bg-white p-2 inline-block">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={displayUrl} alt={label} className="max-h-16 max-w-full object-contain" />
|
||||
{preview && <p className="text-[10px] text-neutral-400 mt-1">Preview — not yet saved</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="relative rounded-lg border-2 border-dashed border-neutral-300 bg-neutral-50 px-4 py-3 text-center cursor-pointer hover:border-primary-400 hover:bg-primary-50 transition-colors"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<Upload className="mx-auto h-5 w-5 text-neutral-400 mb-1" />
|
||||
<p className="text-xs text-neutral-600">Click to select image</p>
|
||||
<p className="text-[10px] text-neutral-400 mt-0.5">{hint}</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg,image/webp"
|
||||
onChange={handleFileChange}
|
||||
className="sr-only"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-danger-700 bg-danger-50 rounded px-2 py-1">{error}</p>}
|
||||
|
||||
{preview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpload}
|
||||
disabled={pending}
|
||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700 disabled:opacity-60"
|
||||
>
|
||||
{pending ? "Uploading…" : "Upload"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,12 +2,10 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||
import { createCompany, updateCompany } from "./actions";
|
||||
import { CompanyBrandingUploader } from "./company-branding-uploader";
|
||||
|
||||
export type CompanyFormData = {
|
||||
type CompanyRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string | null;
|
||||
|
|
@ -18,15 +16,13 @@ export type CompanyFormData = {
|
|||
email: string | null;
|
||||
invoiceEmail: string | null;
|
||||
invoiceAddress: string | null;
|
||||
logoUrl: string | null;
|
||||
stampUrl: string | null;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
||||
const LABEL = "block text-xs font-medium text-neutral-700 mb-1";
|
||||
|
||||
function CompanyFormFields({ company }: { company?: CompanyFormData }) {
|
||||
function CompanyFormFields({ company }: { company?: CompanyRow }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
|
|
@ -75,79 +71,92 @@ function CompanyFormFields({ company }: { company?: CompanyFormData }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function CompanyForm({ company }: { company?: CompanyFormData }) {
|
||||
export function AddCompanyButton() {
|
||||
const router = useRouter();
|
||||
const isEdit = !!company?.id;
|
||||
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 fd = new FormData(e.currentTarget);
|
||||
|
||||
if (isEdit) {
|
||||
fd.set("id", company!.id);
|
||||
const result = await updateCompany(fd);
|
||||
if ("error" in result) { setError(result.error); setPending(false); return; }
|
||||
router.push("/admin/companies");
|
||||
router.refresh();
|
||||
} else {
|
||||
const result = await createCompany(fd);
|
||||
if ("error" in result) { setError(result.error); setPending(false); return; }
|
||||
// Land on the edit page so the logo/stamp can be uploaded against the new company.
|
||||
router.push(`/admin/companies/${result.id}/edit`);
|
||||
router.refresh();
|
||||
}
|
||||
e.preventDefault(); setPending(true); setError("");
|
||||
const result = await createCompany(new FormData(e.currentTarget));
|
||||
if ("error" in result) { setError(result.error); setPending(false); }
|
||||
else { setPending(false); setOpen(false); router.refresh(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<Link href="/admin/companies" className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-700 mb-3">
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> Back to Companies
|
||||
</Link>
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">{isEdit ? `Edit — ${company!.name}` : "Add Company"}</h1>
|
||||
<p className="text-sm text-neutral-500 mt-0.5 mb-6">Sister company used for invoicing and purchase orders</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<CompanyFormFields company={company} />
|
||||
</div>
|
||||
{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">
|
||||
<Link href="/admin/companies"
|
||||
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
||||
Cancel
|
||||
</Link>
|
||||
<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 ? (isEdit ? "Saving…" : "Creating…") : (isEdit ? "Save Changes" : "Create Company")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* ── Branding (independent uploads; available once the company exists) ── */}
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-5 mt-6">
|
||||
<h2 className="text-sm font-semibold text-neutral-800">Branding</h2>
|
||||
<p className="text-xs text-neutral-400 mb-3">Logo and stamp shown on exported POs</p>
|
||||
{isEdit ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<CompanyBrandingUploader
|
||||
companyId={company!.id} type="logo" label="Logo"
|
||||
hint="PNG, JPG or WebP — shown top-left. Max 4 MB"
|
||||
currentUrl={company!.logoUrl}
|
||||
/>
|
||||
<CompanyBrandingUploader
|
||||
companyId={company!.id} type="stamp" label="Stamp / Seal"
|
||||
hint="PNG, JPG or WebP — shown in signatory block. Max 4 MB"
|
||||
currentUrl={company!.stampUrl}
|
||||
/>
|
||||
<>
|
||||
<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 Company
|
||||
</button>
|
||||
<AdminDialog title="Add Company" open={open} onClose={() => setOpen(false)}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<CompanyFormFields />
|
||||
{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 Company"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-neutral-400">Create the company first — you'll be taken to the edit page where you can upload a logo and stamp.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditCompanyButton({
|
||||
company,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
company: CompanyRow;
|
||||
open?: boolean;
|
||||
onOpenChange?: (v: boolean) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); setPending(true); setError("");
|
||||
const fd = new FormData(e.currentTarget);
|
||||
fd.set("id", company.id);
|
||||
const result = await updateCompany(fd);
|
||||
if ("error" in result) { setError(result.error); setPending(false); }
|
||||
else { setPending(false); setOpen(false); router.refresh(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isControlled && (
|
||||
<button onClick={() => setOpen(true)}
|
||||
className="rounded border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100 transition-colors">
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<AdminDialog title={`Edit — ${company.name}`} open={open} onClose={() => setOpen(false)}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<CompanyFormFields company={company} />
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
import { auth } from "@/auth";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
import { CompanyForm } from "../company-form";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Add Company" };
|
||||
|
||||
export default async function NewCompanyPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard");
|
||||
|
||||
return <CompanyForm />;
|
||||
}
|
||||
|
|
@ -3,8 +3,8 @@ import { db } from "@/lib/db";
|
|||
import { StatCard } from "@/components/dashboard/stat-card";
|
||||
import { SpendCharts } from "@/components/dashboard/spend-charts";
|
||||
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
||||
import { formatCurrency, formatCompactINR, formatDate, POST_APPROVAL_STATUSES } from "@/lib/utils";
|
||||
import { FileText, Clock, CheckCircle, DollarSign, IndianRupee } from "lucide-react";
|
||||
import { formatCurrency, formatDate, POST_APPROVAL_STATUSES } from "@/lib/utils";
|
||||
import { FileText, Clock, CheckCircle, DollarSign } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
|
|
@ -182,7 +182,7 @@ async function ManagerDashboard() {
|
|||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<StatCard label="Awaiting Approval" value={awaitingCount} icon={Clock} color="orange" href="/approvals" />
|
||||
<StatCard label="Approved This Month" value={approvedThisMonth} icon={CheckCircle} color="green" href={`/history?approvedFrom=${startOfMonthParam}`} />
|
||||
<StatCard label="Total Approved Spend" value={formatCompactINR(totalSpend)} icon={IndianRupee} color="blue" />
|
||||
<StatCard label="Total Approved Spend" value={formatCurrency(totalSpend)} icon={DollarSign} color="blue" />
|
||||
</div>
|
||||
|
||||
{/* Recent approved POs */}
|
||||
|
|
|
|||
|
|
@ -23,22 +23,6 @@ function fmtNum(n: number, dec = 2): string {
|
|||
return n.toLocaleString("en-IN", { minimumFractionDigits: dec, maximumFractionDigits: dec });
|
||||
}
|
||||
|
||||
// Fixed brand bar colour shown at the bottom of every exported PO (matches the sample PO).
|
||||
const BRAND_BAR_COLOR = "#92D050";
|
||||
|
||||
function mimeForKey(key: string): string {
|
||||
const ext = key.split(".").pop()?.toLowerCase();
|
||||
return ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
|
||||
}
|
||||
|
||||
// Download a stored image and return it base64-encoded (or null if missing).
|
||||
async function fetchImage(key: string | null | undefined): Promise<{ base64: string; mime: string } | null> {
|
||||
if (!key) return null;
|
||||
const buf = await downloadBuffer(key);
|
||||
if (!buf) return null;
|
||||
return { base64: buf.toString("base64"), mime: mimeForKey(key) };
|
||||
}
|
||||
|
||||
// ── Route ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props { params: Promise<{ id: string }> }
|
||||
|
|
@ -141,10 +125,6 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
}
|
||||
}
|
||||
|
||||
// Company branding (logo top-left, stamp/seal in the signatory block)
|
||||
const logoImg = await fetchImage(co?.logoKey);
|
||||
const stampImg = await fetchImage(co?.stampKey);
|
||||
|
||||
const ext = po as {
|
||||
piQuotationNo?: string | null; piQuotationDate?: Date | null;
|
||||
requisitionNo?: string | null; requisitionDate?: Date | null;
|
||||
|
|
@ -275,19 +255,6 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
ws.mergeCells("A4:I4");
|
||||
ws.getRow(4).border = { top: thin(), bottom: thin() };
|
||||
|
||||
// ══ Company logo (floats top-left over the header, columns A-B) ══════════
|
||||
if (logoImg) {
|
||||
const logoId = wb.addImage({
|
||||
base64: logoImg.base64,
|
||||
extension: logoImg.mime === "image/jpeg" ? "jpeg" : "png",
|
||||
});
|
||||
ws.addImage(logoId, {
|
||||
tl: { col: 0.1, row: 0.1 } as unknown as ExcelJS.Anchor,
|
||||
br: { col: 1.9, row: 2.9 } as unknown as ExcelJS.Anchor,
|
||||
editAs: "oneCell",
|
||||
});
|
||||
}
|
||||
|
||||
// ══ ROW 5: PO Number & Date ══════════════════════════════════════════════
|
||||
ws.getRow(5).height = 18;
|
||||
sc(5, 1, "Purchase Order No:", { font: fBold, fill: fillLbl, border: bordAll, align: alignL });
|
||||
|
|
@ -478,19 +445,6 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
ws.getRow(SIG_ROW + 2).height = 14;
|
||||
ws.getRow(SIG_ROW + 3).height = 14;
|
||||
|
||||
// Company stamp / seal — overlays the right of the approver's signatory block (cols C-D)
|
||||
if (stampImg) {
|
||||
const stampId = wb.addImage({
|
||||
base64: stampImg.base64,
|
||||
extension: stampImg.mime === "image/jpeg" ? "jpeg" : "png",
|
||||
});
|
||||
ws.addImage(stampId, {
|
||||
tl: { col: 2.2, row: SIG_ROW - 1 } as unknown as ExcelJS.Anchor,
|
||||
br: { col: 3.9, row: SIG_ROW + 2 } as unknown as ExcelJS.Anchor,
|
||||
editAs: "oneCell",
|
||||
});
|
||||
}
|
||||
|
||||
// Right sig block (vendor)
|
||||
const vName = po.vendor?.name ?? "";
|
||||
sc(SIG_ROW, 6, vName, { font: fBold, border: { top: thin(), left: thin(), right: thin() }, align: alignC });
|
||||
|
|
@ -500,14 +454,6 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
sc(SIG_ROW + 2, 6, `For, ${vName}`, { font: fSmall, border: { left: thin(), bottom: thin(), right: thin() }, align: alignC });
|
||||
ws.mergeCells(`F${SIG_ROW + 2}:I${SIG_ROW + 2}`);
|
||||
|
||||
// ══ Brand bar (full-width colour strip at the very bottom) ═══════════════
|
||||
const BAR_ROW = SIG_ROW + 4;
|
||||
const barArgb = "FF" + BRAND_BAR_COLOR.replace("#", "").toUpperCase();
|
||||
const barFill = { type: "pattern" as const, pattern: "solid" as const, fgColor: { argb: barArgb } };
|
||||
ws.getRow(BAR_ROW).height = 16;
|
||||
for (let c = 1; c <= 9; c++) sc(BAR_ROW, c, "", { fill: barFill });
|
||||
ws.mergeCells(`A${BAR_ROW}:I${BAR_ROW}`);
|
||||
|
||||
// ── Serialise ─────────────────────────────────────────────────────────
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
const slug = po.poNumber.replace(/\//g, "-");
|
||||
|
|
@ -560,20 +506,9 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
color: #111;
|
||||
margin: 10mm 12mm;
|
||||
line-height: 1.3;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.header-band { position: relative; }
|
||||
.co-logo {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
max-height: 52px;
|
||||
max-width: 92px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.co-name {
|
||||
text-align: center;
|
||||
font-size: 13pt;
|
||||
|
|
@ -633,7 +568,6 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
/* ── Signatures ── */
|
||||
.sig { display: flex; justify-content: space-between; margin-top: 14px; }
|
||||
.sig-box {
|
||||
position: relative;
|
||||
border: 1px solid #999;
|
||||
width: 44%;
|
||||
min-height: 60px;
|
||||
|
|
@ -645,26 +579,9 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
}
|
||||
.sig-name { font-weight: bold; font-size: 9pt; min-height: 32px; }
|
||||
.sig-sub { font-size: 7.5pt; }
|
||||
.sig-stamp {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 4px;
|
||||
max-height: 66px;
|
||||
max-width: 88px;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.spacer { margin: 4px 0; }
|
||||
|
||||
/* ── Brand bar (bottom) ── */
|
||||
.brand-bar {
|
||||
height: 14px;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
background: ${BRAND_BAR_COLOR};
|
||||
}
|
||||
|
||||
@media print {
|
||||
.no-print { display: none; }
|
||||
body { margin: 8mm 10mm; }
|
||||
|
|
@ -681,12 +598,9 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
</div>
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────────────── -->
|
||||
<div class="header-band">
|
||||
${logoImg ? `<img class="co-logo" src="data:${logoImg.mime};base64,${logoImg.base64}" alt="Logo" />` : ""}
|
||||
<div class="co-name">${CO_NAME}</div>
|
||||
<div class="co-addr">${CO_ADDR}</div>
|
||||
<div class="co-tel">${CO_TEL}</div>
|
||||
</div>
|
||||
<div class="co-name">${CO_NAME}</div>
|
||||
<div class="co-addr">${CO_ADDR}</div>
|
||||
<div class="co-tel">${CO_TEL}</div>
|
||||
<div class="po-title">PURCHASE ORDER</div>
|
||||
|
||||
<!-- ── PO Meta & Quotation ──────────────────────────────────── -->
|
||||
|
|
@ -804,7 +718,6 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
<!-- ── Signatures ────────────────────────────────────────────── -->
|
||||
<div class="sig">
|
||||
<div class="sig-box">
|
||||
${stampImg ? `<img class="sig-stamp" src="data:${stampImg.mime};base64,${stampImg.base64}" alt="Stamp" />` : ""}
|
||||
${signatureBase64
|
||||
? `<img src="data:${signatureMime};base64,${signatureBase64}" alt="Signature" style="max-height:48px;max-width:180px;object-fit:contain;display:block;margin:0 auto 4px;" />`
|
||||
: `<div class="sig-name">${approvedBy}</div>`
|
||||
|
|
@ -812,7 +725,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
<div>
|
||||
<div class="sig-sub" style="font-weight:bold">${approvedBy}</div>
|
||||
<div class="sig-sub">Authorized Signatory & Stamp</div>
|
||||
<div class="sig-sub">For, ${CO_NAME}</div>
|
||||
<div class="sig-sub">For, Pelagia Marine Services Pvt. Ltd.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sig-box">
|
||||
|
|
@ -824,9 +737,6 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Brand bar ─────────────────────────────────────────────── -->
|
||||
<div class="brand-bar"></div>
|
||||
|
||||
<script>window.onload = function() { window.print(); };</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
|
|
|||
|
|
@ -20,11 +20,8 @@ const UOM_OPTIONS = [
|
|||
{ value: "mL", label: "mL — Millilitre" },
|
||||
{ value: "m", label: "m — Metre" },
|
||||
{ value: "m2", label: "m² — Sq. Metre" },
|
||||
{ value: "hr", label: "hr — Hour" },
|
||||
{ value: "day", label: "day — Day" },
|
||||
{ value: "week", label: "week — Week" },
|
||||
{ value: "month", label: "month — Month" },
|
||||
{ value: "year", label: "year — Year" },
|
||||
{ value: "hr", label: "hr — Hour" },
|
||||
{ value: "day", label: "day — Day" },
|
||||
{ value: "lump", label: "lump — Lump Sum" },
|
||||
{ value: "Ltr", label: "Ltr — Litre (alt)" },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -57,18 +57,6 @@ export function buildSignatureKey(userId: string, ext: string): string {
|
|||
return `signatures/${userId}.${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage key for a company branding asset (logo or stamp/seal).
|
||||
* Deterministic per company+type so a re-upload overwrites the previous file.
|
||||
*/
|
||||
export function buildCompanyAssetKey(
|
||||
companyId: string,
|
||||
type: "logo" | "stamp",
|
||||
ext: string
|
||||
): string {
|
||||
return `company-assets/${companyId}/${type}.${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file buffer directly to storage (server-side).
|
||||
* In dev: writes to .dev-uploads/. In prod: PUTs to R2.
|
||||
|
|
|
|||
|
|
@ -12,30 +12,6 @@ export function formatCurrency(amount: number | string, currency = "INR"): strin
|
|||
);
|
||||
}
|
||||
|
||||
// Compact INR formatter using the Indian short scale (lakh = 1e5, crore = 1e7).
|
||||
// Produces readable abbreviations for dashboard stat cards, e.g. ₹2 Cr, ₹49 L,
|
||||
// ₹75 K, ₹500. Values are rounded to at most 2 decimals with trailing zeros
|
||||
// trimmed (₹2.5 Cr, not ₹2.50 Cr). Negative amounts keep their sign.
|
||||
export function formatCompactINR(amount: number | string): string {
|
||||
const n = Number(amount);
|
||||
if (!Number.isFinite(n)) return "₹0";
|
||||
|
||||
const sign = n < 0 ? "-" : "";
|
||||
const abs = Math.abs(n);
|
||||
|
||||
const format = (value: number, suffix: string) => {
|
||||
const rounded = Math.round(value * 100) / 100;
|
||||
// Trim trailing zeros: 2 -> "2", 2.5 -> "2.5", 2.05 -> "2.05".
|
||||
const text = rounded.toFixed(2).replace(/\.?0+$/, "");
|
||||
return `${sign}₹${text}${suffix}`;
|
||||
};
|
||||
|
||||
if (abs >= 1e7) return format(abs / 1e7, " Cr");
|
||||
if (abs >= 1e5) return format(abs / 1e5, " L");
|
||||
if (abs >= 1e3) return format(abs / 1e3, " K");
|
||||
return format(abs, "");
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
-- Add branding to Company: logo + stamp images, shown on exported POs
|
||||
ALTER TABLE "Company" ADD COLUMN "logoKey" TEXT;
|
||||
ALTER TABLE "Company" ADD COLUMN "stampKey" TEXT;
|
||||
|
|
@ -125,8 +125,6 @@ model Company {
|
|||
email String?
|
||||
invoiceEmail String?
|
||||
invoiceAddress String?
|
||||
logoKey String? // storage key for uploaded logo image (top of exported POs)
|
||||
stampKey String? // storage key for uploaded company stamp/seal (signatory block of exported POs)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
/**
|
||||
* Integration tests for company branding actions (logo + stamp uploads).
|
||||
* Covers:
|
||||
* - Manager can upload a logo / stamp; the key is stored on the company
|
||||
* - Re-upload overwrites in place (deterministic key)
|
||||
* - Invalid asset type, bad mime, and oversize files are rejected
|
||||
* - removeCompanyAsset clears the key
|
||||
* - Permission gating (TECHNICAL cannot manage branding)
|
||||
*/
|
||||
import { vi, describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("@/lib/storage", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/storage")>()),
|
||||
uploadBuffer: vi.fn(), // don't touch the filesystem in tests
|
||||
}));
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { uploadBuffer } from "@/lib/storage";
|
||||
import { uploadCompanyAsset, removeCompanyAsset } from "@/app/(portal)/admin/companies/actions";
|
||||
import { makeSession } from "./helpers";
|
||||
|
||||
const mockedAuth = vi.mocked(auth);
|
||||
const mockedUpload = vi.mocked(uploadBuffer);
|
||||
|
||||
let companyId: string;
|
||||
|
||||
function pngFile(name: string, bytes = 1024): File {
|
||||
return new File([new Uint8Array(bytes)], name, { type: "image/png" });
|
||||
}
|
||||
|
||||
function assetForm(id: string, type: string, file: File): FormData {
|
||||
const form = new FormData();
|
||||
form.set("companyId", id);
|
||||
form.set("type", type);
|
||||
form.set("file", file);
|
||||
return form;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const company = await db.company.create({
|
||||
data: { name: "INTTEST_BRANDING_CO", code: "ZZBRAND" },
|
||||
});
|
||||
companyId = company.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.company.delete({ where: { id: companyId } }).catch(() => {});
|
||||
});
|
||||
|
||||
describe("uploadCompanyAsset", () => {
|
||||
it("stores a logo key on the company", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||
const res = await uploadCompanyAsset(assetForm(companyId, "logo", pngFile("logo.png")));
|
||||
expect(res).toEqual({ ok: true });
|
||||
const c = await db.company.findUniqueOrThrow({ where: { id: companyId } });
|
||||
expect(c.logoKey).toBe(`company-assets/${companyId}/logo.png`);
|
||||
expect(mockedUpload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores a stamp key independently of the logo", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||
const res = await uploadCompanyAsset(assetForm(companyId, "stamp", pngFile("stamp.png")));
|
||||
expect(res).toEqual({ ok: true });
|
||||
const c = await db.company.findUniqueOrThrow({ where: { id: companyId } });
|
||||
expect(c.stampKey).toBe(`company-assets/${companyId}/stamp.png`);
|
||||
expect(c.logoKey).toBe(`company-assets/${companyId}/logo.png`);
|
||||
});
|
||||
|
||||
it("rejects an unknown asset type", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||
const res = await uploadCompanyAsset(assetForm(companyId, "header", pngFile("x.png")));
|
||||
expect(res).toEqual({ error: "Invalid asset type" });
|
||||
});
|
||||
|
||||
it("rejects a non-image mime type", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||
const pdf = new File([new Uint8Array(10)], "x.pdf", { type: "application/pdf" });
|
||||
const res = await uploadCompanyAsset(assetForm(companyId, "logo", pdf));
|
||||
expect(res).toEqual({ error: "Image must be a PNG, JPG, or WebP" });
|
||||
});
|
||||
|
||||
it("rejects a file over 4 MB", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||
const big = pngFile("big.png", 5 * 1024 * 1024);
|
||||
const res = await uploadCompanyAsset(assetForm(companyId, "logo", big));
|
||||
expect(res).toEqual({ error: "Image must be under 4 MB" });
|
||||
});
|
||||
|
||||
it("refuses callers without manage_vessels_accounts", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
||||
const res = await uploadCompanyAsset(assetForm(companyId, "logo", pngFile("logo.png")));
|
||||
expect(res).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeCompanyAsset", () => {
|
||||
it("clears the stored key", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||
const res = await removeCompanyAsset(companyId, "logo");
|
||||
expect(res).toEqual({ ok: true });
|
||||
const c = await db.company.findUniqueOrThrow({ where: { id: companyId } });
|
||||
expect(c.logoKey).toBeNull();
|
||||
expect(c.stampKey).toBe(`company-assets/${companyId}/stamp.png`); // stamp untouched
|
||||
});
|
||||
|
||||
it("refuses unauthorized callers", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
||||
const res = await removeCompanyAsset(companyId, "stamp");
|
||||
expect(res).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
/**
|
||||
* Integration tests for company create/update actions.
|
||||
* Focus on the behaviour the dedicated add/edit pages rely on:
|
||||
* - createCompany returns the new id (so the create flow can redirect to the edit page)
|
||||
* - fields persist, code is upper-cased, duplicate codes are rejected
|
||||
* - updateCompany edits in place
|
||||
* - both actions are gated by manage_vessels_accounts
|
||||
*/
|
||||
import { vi, describe, it, expect, afterAll } from "vitest";
|
||||
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { createCompany, updateCompany } from "@/app/(portal)/admin/companies/actions";
|
||||
import { makeSession, fd } from "./helpers";
|
||||
|
||||
const mockedAuth = vi.mocked(auth);
|
||||
const NAME_PREFIX = "INTTEST_CRUD_";
|
||||
|
||||
afterAll(async () => {
|
||||
await db.company.deleteMany({ where: { name: { startsWith: NAME_PREFIX } } });
|
||||
});
|
||||
|
||||
describe("createCompany", () => {
|
||||
it("returns the new id and persists the company (code upper-cased)", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||
const result = await createCompany(fd({
|
||||
name: `${NAME_PREFIX}Alpha`,
|
||||
code: "zzcrudA",
|
||||
gstNumber: "27AAHCP5787B1Z6",
|
||||
}));
|
||||
|
||||
expect("id" in result && result.ok).toBe(true);
|
||||
if (!("id" in result)) throw new Error(result.error);
|
||||
|
||||
const c = await db.company.findUniqueOrThrow({ where: { id: result.id } });
|
||||
expect(c.name).toBe(`${NAME_PREFIX}Alpha`);
|
||||
expect(c.code).toBe("ZZCRUDA");
|
||||
expect(c.gstNumber).toBe("27AAHCP5787B1Z6");
|
||||
});
|
||||
|
||||
it("rejects a duplicate code (case-insensitive)", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||
const first = await createCompany(fd({ name: `${NAME_PREFIX}Dup1`, code: "zzcrudd" }));
|
||||
expect("id" in first).toBe(true);
|
||||
|
||||
const second = await createCompany(fd({ name: `${NAME_PREFIX}Dup2`, code: "ZZCRUDD" }));
|
||||
expect("error" in second).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses callers without manage_vessels_accounts", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
||||
const result = await createCompany(fd({ name: `${NAME_PREFIX}Nope`, code: "zzcrudN" }));
|
||||
expect(result).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateCompany", () => {
|
||||
it("edits an existing company in place", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||
const created = await createCompany(fd({ name: `${NAME_PREFIX}Edit`, code: "zzcrudE" }));
|
||||
if (!("id" in created)) throw new Error(created.error);
|
||||
|
||||
const result = await updateCompany(fd({
|
||||
id: created.id,
|
||||
name: `${NAME_PREFIX}Edited`,
|
||||
code: "zzcrudE",
|
||||
mobile: "+91 99999 00000",
|
||||
}));
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
||||
const c = await db.company.findUniqueOrThrow({ where: { id: created.id } });
|
||||
expect(c.name).toBe(`${NAME_PREFIX}Edited`);
|
||||
expect(c.mobile).toBe("+91 99999 00000");
|
||||
});
|
||||
|
||||
it("refuses callers without manage_vessels_accounts", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
||||
const result = await updateCompany(fd({ id: "whatever", name: "x", code: "ZZX" }));
|
||||
expect(result).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
});
|
||||
|
|
@ -93,25 +93,6 @@ describe("LineItemsEditor — edit mode", () => {
|
|||
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] as LineItemInput[];
|
||||
expect(lastCall[0].gstRate).toBeCloseTo(0.05);
|
||||
});
|
||||
|
||||
it("offers month and year as unit-of-measure options", () => {
|
||||
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
|
||||
const selects = screen.getAllByRole("combobox") as HTMLSelectElement[];
|
||||
const unitSelect = selects.find((s) => s.value === "pc")!;
|
||||
const values = Array.from(unitSelect.options).map((o) => o.value);
|
||||
expect(values).toContain("month");
|
||||
expect(values).toContain("year");
|
||||
});
|
||||
|
||||
it("calls onChange with the selected duration unit", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={onChange} />);
|
||||
const selects = screen.getAllByRole("combobox") as HTMLSelectElement[];
|
||||
const unitSelect = selects.find((s) => s.value === "pc")!;
|
||||
fireEvent.change(unitSelect, { target: { value: "year" } });
|
||||
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] as LineItemInput[];
|
||||
expect(lastCall[0].unit).toBe("year");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Totals calculation (edit mode) ────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildCompanyAssetKey, buildSignatureKey } from "@/lib/storage";
|
||||
|
||||
describe("buildCompanyAssetKey", () => {
|
||||
it("builds a deterministic logo key under the company namespace", () => {
|
||||
expect(buildCompanyAssetKey("cmp123", "logo", "png")).toBe("company-assets/cmp123/logo.png");
|
||||
});
|
||||
|
||||
it("builds a deterministic stamp key", () => {
|
||||
expect(buildCompanyAssetKey("cmp123", "stamp", "webp")).toBe("company-assets/cmp123/stamp.webp");
|
||||
});
|
||||
|
||||
it("is stable across re-uploads of the same type (overwrites in place)", () => {
|
||||
const a = buildCompanyAssetKey("c1", "logo", "png");
|
||||
const b = buildCompanyAssetKey("c1", "logo", "png");
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("separates logo and stamp into distinct keys", () => {
|
||||
expect(buildCompanyAssetKey("c1", "logo", "png")).not.toBe(buildCompanyAssetKey("c1", "stamp", "png"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSignatureKey", () => {
|
||||
it("keeps signatures in their own namespace", () => {
|
||||
expect(buildSignatureKey("u1", "png")).toBe("signatures/u1.png");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatCurrency, formatCompactINR, formatDate, formatDateTime,
|
||||
formatCurrency, formatDate, formatDateTime,
|
||||
generatePoNumber, PO_STATUS_LABELS, PO_STATUS_VARIANTS,
|
||||
} from "@/lib/utils";
|
||||
|
||||
|
|
@ -32,55 +32,6 @@ describe("formatCurrency", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("formatCompactINR", () => {
|
||||
it("abbreviates crore amounts with Cr", () => {
|
||||
expect(formatCompactINR(20000000)).toBe("₹2 Cr");
|
||||
});
|
||||
|
||||
it("abbreviates lakh amounts with L", () => {
|
||||
expect(formatCompactINR(4900000)).toBe("₹49 L");
|
||||
});
|
||||
|
||||
it("abbreviates thousand amounts with K", () => {
|
||||
expect(formatCompactINR(75000)).toBe("₹75 K");
|
||||
});
|
||||
|
||||
it("renders sub-thousand amounts without a suffix", () => {
|
||||
expect(formatCompactINR(500)).toBe("₹500");
|
||||
});
|
||||
|
||||
it("formats zero as ₹0", () => {
|
||||
expect(formatCompactINR(0)).toBe("₹0");
|
||||
});
|
||||
|
||||
it("trims trailing zeros but keeps significant decimals", () => {
|
||||
expect(formatCompactINR(25000000)).toBe("₹2.5 Cr");
|
||||
expect(formatCompactINR(4950000)).toBe("₹49.5 L");
|
||||
});
|
||||
|
||||
it("rounds to at most two decimals", () => {
|
||||
expect(formatCompactINR(12345678)).toBe("₹1.23 Cr");
|
||||
});
|
||||
|
||||
it("uses the right unit at boundaries", () => {
|
||||
expect(formatCompactINR(100000)).toBe("₹1 L");
|
||||
expect(formatCompactINR(10000000)).toBe("₹1 Cr");
|
||||
expect(formatCompactINR(1000)).toBe("₹1 K");
|
||||
});
|
||||
|
||||
it("accepts string input", () => {
|
||||
expect(formatCompactINR("4900000")).toBe("₹49 L");
|
||||
});
|
||||
|
||||
it("preserves the sign for negative amounts", () => {
|
||||
expect(formatCompactINR(-4900000)).toBe("-₹49 L");
|
||||
});
|
||||
|
||||
it("handles non-finite input gracefully", () => {
|
||||
expect(formatCompactINR(NaN)).toBe("₹0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDate", () => {
|
||||
it("returns a readable date string", () => {
|
||||
const result = formatDate(new Date("2026-04-29"));
|
||||
|
|
|
|||
|
|
@ -121,11 +121,7 @@ before a release tag deploys them to prod.
|
|||
- Checkout: `~/pelagia-staging` (separate from `~/pms` and `~/pelagia-autofix`)
|
||||
- Process: pm2 `ppms-staging` on **port 3200**, against the prod-mirror test DB
|
||||
(`pelagia_test`), safe dev mode (console email, local storage, SSO disabled).
|
||||
- **Auto-refresh:** [`.forgejo/workflows/staging.yml`](../.forgejo/workflows/staging.yml)
|
||||
rebuilds staging on **every push to `master`** (i.e. every merged PR) on the host runner,
|
||||
so staging always tracks the trunk. It runs `~/issue-watcher/staging-up.sh`; concurrent
|
||||
runs are coalesced (newest master wins). Also triggerable on demand (`workflow_dispatch`).
|
||||
- Manual refresh / restart: re-run `~/issue-watcher/staging-up.sh`.
|
||||
- Refresh to newer master + restart: re-run `~/issue-watcher/staging-up.sh`.
|
||||
- Stop: `pm2 delete ppms-staging`.
|
||||
- **Access is SSH-tunnel only** — the dev server binds to `127.0.0.1:3200`, so it is
|
||||
not reachable from the public internet. Open a tunnel and browse `http://localhost:3200`:
|
||||
|
|
@ -161,22 +157,16 @@ portal ──(triage)──▶ triaged + claude-queue ─▶ claude-working ─
|
|||
|
||||
## Releasing
|
||||
|
||||
> ⚠️ **Release tags MUST be `v`-prefixed** (e.g. `v0.2.2`). `deploy.yml` triggers only on
|
||||
> `v*` tags — a bare tag like `0.2.2` will **NOT** deploy (the runner ignores it and prod
|
||||
> stays on the previous version). Push the **tag** specifically; pushing `master` alone
|
||||
> never deploys.
|
||||
|
||||
After merging PR(s) on `master`:
|
||||
After merging a Claude PR (or any change) on `master`:
|
||||
|
||||
```powershell
|
||||
git pull
|
||||
git tag v0.2.2 # MUST start with "v"; semver: patch = fixes, minor = features
|
||||
git push pms1 v0.2.2 # pushing the v* tag is what triggers the deploy
|
||||
git tag v0.2.0 # semver: bump patch for fixes, minor for features
|
||||
git push pms1 master --tags
|
||||
```
|
||||
|
||||
The runner checks out the tag in `~/pms`, runs `pnpm install` + `build` +
|
||||
`prisma migrate deploy`, `pm2 restart ppms`, and verifies `/login` returns 200. Watch
|
||||
progress under **Actions** on the Forgejo repo, or `pm2 logs forgejo-runner` on pms1.
|
||||
The runner deploys the tag and restarts the app. Watch progress under
|
||||
**Actions** on the Forgejo repo, or `pm2 logs forgejo-runner` on pms1.
|
||||
|
||||
## Operational notes
|
||||
|
||||
|
|
|
|||
|
|
@ -67,12 +67,8 @@ echo "Generating Prisma client..."; pnpm db:generate
|
|||
# must be applied or the new code 500s on the missing columns.
|
||||
echo "Applying pending migrations to the test DB..."; pnpm db:migrate:deploy
|
||||
|
||||
# Drop any FORGEJO_* the caller may carry (e.g. when invoked from the Forgejo
|
||||
# Actions runner, whose ephemeral FORGEJO_TOKEN would otherwise be injected into
|
||||
# the staging process). NOT --update-env on restart, for the same reason.
|
||||
for v in $(env | grep -oE '^FORGEJO_[A-Z_]+' || true); do unset "$v"; done
|
||||
if pm2 describe "$NAME" >/dev/null 2>&1; then
|
||||
pm2 restart "$NAME"
|
||||
pm2 restart "$NAME" --update-env
|
||||
else
|
||||
pm2 start "$DIR/App/run-staging.sh" --name "$NAME" --interpreter bash
|
||||
fi
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue