fix: remove nested .git folders, re-add as normal directories
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import "../../styles/procurement.css";
|
||||
|
||||
const tabs = [
|
||||
{ label: "Product Catalog", href: "/procurement/catalog", exact: false },
|
||||
] as const;
|
||||
|
||||
function isActive(
|
||||
tab: { href: string; exact?: boolean },
|
||||
pathname: string,
|
||||
): boolean {
|
||||
if (tab.exact) return pathname === tab.href;
|
||||
return pathname.startsWith(tab.href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Procurement — Project Optima</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="procurement-page">
|
||||
<div class="procurement-pane">
|
||||
<!-- Pane header + tabs in one row -->
|
||||
<div class="procurement-header">
|
||||
<div class="procurement-header-left">
|
||||
<svg
|
||||
class="procurement-header-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
|
||||
></path>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"></line>
|
||||
</svg>
|
||||
<h2 class="procurement-title">Procurement</h2>
|
||||
</div>
|
||||
<nav class="tab-bar" role="tablist">
|
||||
{#each tabs as tab}
|
||||
<a
|
||||
href={tab.href}
|
||||
class="tab-btn"
|
||||
class:active={isActive(tab, $page.url.pathname)}
|
||||
role="tab"
|
||||
aria-selected={isActive(tab, $page.url.pathname)}
|
||||
>
|
||||
{tab.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab content -->
|
||||
<div class="procurement-body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
onMount(() => {
|
||||
goto("/procurement/catalog", { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,60 @@
|
||||
import { optima } from "$lib";
|
||||
import { handleApiError } from "$lib/optima-api/errorHandler";
|
||||
import { checkPermissions } from "$lib/permissions";
|
||||
import type { PageServerLoad } from "./$types";
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
const accessToken = locals.session?.accessToken;
|
||||
if (!accessToken) {
|
||||
return {
|
||||
items: [],
|
||||
totalPages: 1,
|
||||
currentPage: 1,
|
||||
totalRecords: 0,
|
||||
search: "",
|
||||
permissions: {},
|
||||
};
|
||||
}
|
||||
|
||||
const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
|
||||
const search = url.searchParams.get("search") || "";
|
||||
const includeInactive = url.searchParams.get("includeInactive") === "true";
|
||||
|
||||
try {
|
||||
const [result, permissions] = await Promise.all([
|
||||
optima.procurement
|
||||
.fetchMany(accessToken, page, { search, includeInactive }, 30)
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
"Failed to fetch catalog items:",
|
||||
err?.response?.data ?? err?.message ?? err,
|
||||
);
|
||||
return {
|
||||
data: [],
|
||||
meta: {
|
||||
pagination: { totalPages: 1, currentPage: 1, totalRecords: 0 },
|
||||
},
|
||||
};
|
||||
}),
|
||||
checkPermissions(accessToken, [
|
||||
"procurement.catalog.fetch.many",
|
||||
"procurement.catalog.inventory.refresh",
|
||||
"procurement.catalog.fetch",
|
||||
"procurement.catalog.link",
|
||||
]),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: result?.data ?? [],
|
||||
totalPages: result?.meta?.pagination?.totalPages ?? 1,
|
||||
currentPage: result?.meta?.pagination?.currentPage ?? page,
|
||||
totalRecords:
|
||||
result?.meta?.pagination?.totalRecords ?? result?.data?.length ?? 0,
|
||||
search,
|
||||
includeInactive,
|
||||
permissions,
|
||||
};
|
||||
} catch (err) {
|
||||
handleApiError(err);
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
import { optima } from "$lib";
|
||||
import { json, error } from "@sveltejs/kit";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
/** GET /procurement/catalog/linked?id=<identifier> — fetch linked items */
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const accessToken = locals.session?.accessToken;
|
||||
if (!accessToken) throw error(401, "Unauthorized");
|
||||
|
||||
const identifier = url.searchParams.get("id");
|
||||
if (!identifier) throw error(400, "Missing item id");
|
||||
|
||||
try {
|
||||
const result = await optima.procurement.fetchLinkedItems(
|
||||
accessToken,
|
||||
identifier,
|
||||
);
|
||||
return json(result);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to fetch linked items:", err);
|
||||
const status =
|
||||
err && typeof err === "object" && "status" in err
|
||||
? (err as { status: number }).status
|
||||
: 500;
|
||||
throw error(status, "Failed to fetch linked items");
|
||||
}
|
||||
};
|
||||
|
||||
/** POST /procurement/catalog/linked — link or unlink items */
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const accessToken = locals.session?.accessToken;
|
||||
if (!accessToken) throw error(401, "Unauthorized");
|
||||
|
||||
const body = await request.json();
|
||||
const { action, identifier, targetId } = body;
|
||||
|
||||
if (!identifier || !targetId || !action) {
|
||||
throw error(400, "Missing identifier, targetId, or action");
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === "link") {
|
||||
const result = await optima.procurement.linkItem(
|
||||
accessToken,
|
||||
identifier,
|
||||
targetId,
|
||||
);
|
||||
return json(result);
|
||||
} else if (action === "unlink") {
|
||||
const result = await optima.procurement.unlinkItem(
|
||||
accessToken,
|
||||
identifier,
|
||||
targetId,
|
||||
);
|
||||
return json(result);
|
||||
} else {
|
||||
throw error(400, "Invalid action — must be 'link' or 'unlink'");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error(`Failed to ${action} items:`, err);
|
||||
const status =
|
||||
err && typeof err === "object" && "status" in err
|
||||
? (err as { status: number }).status
|
||||
: 500;
|
||||
throw error(status, `Failed to ${action} items`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({
|
||||
mockOptima: {
|
||||
procurement: {
|
||||
fetchLinkedItems: vi.fn(),
|
||||
linkItem: vi.fn(),
|
||||
unlinkItem: vi.fn(),
|
||||
},
|
||||
},
|
||||
mockJson: vi.fn((data, init?) => {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: init?.status ?? 200,
|
||||
});
|
||||
}),
|
||||
mockError: vi.fn((status: number, message: string) => {
|
||||
throw { status, body: { message } };
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("$lib", () => ({ optima: mockOptima }));
|
||||
vi.mock("@sveltejs/kit", () => ({ json: mockJson, error: mockError }));
|
||||
|
||||
import { GET, POST } from "./+server";
|
||||
|
||||
describe("/procurement/catalog/linked", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("GET", () => {
|
||||
it("throws 401 when no access token", async () => {
|
||||
const event = {
|
||||
locals: {},
|
||||
url: new URL("http://localhost/linked?id=item-1"),
|
||||
};
|
||||
await expect(GET(event as any)).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it("throws 400 when id is missing", async () => {
|
||||
const event = {
|
||||
locals: { session: { accessToken: "tok" } },
|
||||
url: new URL("http://localhost/linked"),
|
||||
};
|
||||
await expect(GET(event as any)).rejects.toEqual(
|
||||
expect.objectContaining({ status: 400 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches linked items", async () => {
|
||||
mockOptima.procurement.fetchLinkedItems.mockResolvedValueOnce({
|
||||
data: [{ id: "linked-1" }],
|
||||
});
|
||||
|
||||
const event = {
|
||||
locals: { session: { accessToken: "tok" } },
|
||||
url: new URL("http://localhost/linked?id=item-1"),
|
||||
};
|
||||
|
||||
await GET(event as any);
|
||||
|
||||
expect(mockOptima.procurement.fetchLinkedItems).toHaveBeenCalledWith(
|
||||
"tok",
|
||||
"item-1",
|
||||
);
|
||||
expect(mockJson).toHaveBeenCalledWith({ data: [{ id: "linked-1" }] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST", () => {
|
||||
it("throws 401 when no access token", async () => {
|
||||
const event = {
|
||||
locals: {},
|
||||
request: {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
action: "link",
|
||||
identifier: "a",
|
||||
targetId: "b",
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(POST(event as any)).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it("throws 400 when fields are missing", async () => {
|
||||
const event = {
|
||||
locals: { session: { accessToken: "tok" } },
|
||||
request: {
|
||||
json: vi.fn().mockResolvedValue({ action: "link" }),
|
||||
},
|
||||
};
|
||||
await expect(POST(event as any)).rejects.toEqual(
|
||||
expect.objectContaining({ status: 400 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("links items", async () => {
|
||||
mockOptima.procurement.linkItem.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const event = {
|
||||
locals: { session: { accessToken: "tok" } },
|
||||
request: {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
action: "link",
|
||||
identifier: "a",
|
||||
targetId: "b",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await POST(event as any);
|
||||
|
||||
expect(mockOptima.procurement.linkItem).toHaveBeenCalledWith(
|
||||
"tok",
|
||||
"a",
|
||||
"b",
|
||||
);
|
||||
expect(mockJson).toHaveBeenCalledWith({ ok: true });
|
||||
});
|
||||
|
||||
it("unlinks items", async () => {
|
||||
mockOptima.procurement.unlinkItem.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const event = {
|
||||
locals: { session: { accessToken: "tok" } },
|
||||
request: {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
action: "unlink",
|
||||
identifier: "a",
|
||||
targetId: "b",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await POST(event as any);
|
||||
|
||||
expect(mockOptima.procurement.unlinkItem).toHaveBeenCalledWith(
|
||||
"tok",
|
||||
"a",
|
||||
"b",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws 400 for invalid action", async () => {
|
||||
const event = {
|
||||
locals: { session: { accessToken: "tok" } },
|
||||
request: {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
action: "destroy",
|
||||
identifier: "a",
|
||||
targetId: "b",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(POST(event as any)).rejects.toEqual(
|
||||
expect.objectContaining({ status: 400 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockOptima, mockCheckPermissions, mockHandleApiError } = vi.hoisted(
|
||||
() => ({
|
||||
mockOptima: {
|
||||
procurement: { fetchMany: vi.fn() },
|
||||
},
|
||||
mockCheckPermissions: vi.fn(),
|
||||
mockHandleApiError: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("$lib", () => ({ optima: mockOptima }));
|
||||
vi.mock("$lib/permissions", () => ({
|
||||
checkPermissions: mockCheckPermissions,
|
||||
}));
|
||||
vi.mock("$lib/optima-api/errorHandler", () => ({
|
||||
handleApiError: mockHandleApiError,
|
||||
}));
|
||||
|
||||
import { load } from "./+page.server";
|
||||
|
||||
describe("procurement/catalog +page.server.ts load", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty data when no token", async () => {
|
||||
const result = await load({
|
||||
locals: {},
|
||||
url: new URL("http://localhost/procurement/catalog"),
|
||||
} as any);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
items: [],
|
||||
totalPages: 1,
|
||||
currentPage: 1,
|
||||
totalRecords: 0,
|
||||
search: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches catalog items with pagination", async () => {
|
||||
mockOptima.procurement.fetchMany.mockResolvedValueOnce({
|
||||
data: [{ id: "item-1" }],
|
||||
meta: {
|
||||
pagination: { totalPages: 2, currentPage: 1, totalRecords: 45 },
|
||||
},
|
||||
});
|
||||
mockCheckPermissions.mockResolvedValueOnce({
|
||||
"procurement.catalog.fetch.many": true,
|
||||
});
|
||||
|
||||
const result = await load({
|
||||
locals: { session: { accessToken: "tok" } },
|
||||
url: new URL("http://localhost/procurement/catalog?page=1&search=widget"),
|
||||
} as any);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
items: [{ id: "item-1" }],
|
||||
totalPages: 2,
|
||||
totalRecords: 45,
|
||||
search: "widget",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes includeInactive when param is true", async () => {
|
||||
mockOptima.procurement.fetchMany.mockResolvedValueOnce({
|
||||
data: [],
|
||||
meta: {
|
||||
pagination: { totalPages: 1, currentPage: 1, totalRecords: 0 },
|
||||
},
|
||||
});
|
||||
mockCheckPermissions.mockResolvedValueOnce({});
|
||||
|
||||
await load({
|
||||
locals: { session: { accessToken: "tok" } },
|
||||
url: new URL("http://localhost/procurement/catalog?includeInactive=true"),
|
||||
} as any);
|
||||
|
||||
expect(mockOptima.procurement.fetchMany).toHaveBeenCalledWith(
|
||||
"tok",
|
||||
1,
|
||||
{ search: "", includeInactive: true },
|
||||
30,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { optima } from "$lib";
|
||||
import { json, error } from "@sveltejs/kit";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
/** GET /procurement/catalog/search?q=<query> — search catalog items for linking */
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const accessToken = locals.session?.accessToken;
|
||||
if (!accessToken) throw error(401, "Unauthorized");
|
||||
|
||||
const query = url.searchParams.get("q") || "";
|
||||
if (!query.trim()) return json({ data: [] });
|
||||
|
||||
try {
|
||||
const result = await optima.procurement.fetchMany(
|
||||
accessToken,
|
||||
1,
|
||||
{ search: query, includeInactive: true },
|
||||
20,
|
||||
);
|
||||
return json({ data: result?.data ?? [] });
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to search catalog items:", err);
|
||||
const status =
|
||||
err && typeof err === "object" && "status" in err
|
||||
? (err as { status: number }).status
|
||||
: 500;
|
||||
throw error(status, "Failed to search catalog items");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockOptima, mockJson, mockError } = vi.hoisted(() => ({
|
||||
mockOptima: {
|
||||
procurement: { fetchMany: vi.fn() },
|
||||
},
|
||||
mockJson: vi.fn((data, init?) => {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: init?.status ?? 200,
|
||||
});
|
||||
}),
|
||||
mockError: vi.fn((status: number, message: string) => {
|
||||
throw { status, body: { message } };
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("$lib", () => ({ optima: mockOptima }));
|
||||
vi.mock("@sveltejs/kit", () => ({ json: mockJson, error: mockError }));
|
||||
|
||||
import { GET } from "./+server";
|
||||
|
||||
describe("GET /procurement/catalog/search", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("throws 401 when no access token", async () => {
|
||||
const event = {
|
||||
locals: {},
|
||||
url: new URL("http://localhost/search?q=widget"),
|
||||
};
|
||||
await expect(GET(event as any)).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it("returns empty data when query is empty", async () => {
|
||||
const event = {
|
||||
locals: { session: { accessToken: "tok" } },
|
||||
url: new URL("http://localhost/search"),
|
||||
};
|
||||
|
||||
GET(event as any);
|
||||
|
||||
expect(mockJson).toHaveBeenCalledWith({ data: [] });
|
||||
expect(mockOptima.procurement.fetchMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("searches catalog items", async () => {
|
||||
mockOptima.procurement.fetchMany.mockResolvedValueOnce({
|
||||
data: [{ id: "item-1", name: "Widget" }],
|
||||
});
|
||||
|
||||
const event = {
|
||||
locals: { session: { accessToken: "tok" } },
|
||||
url: new URL("http://localhost/search?q=widget"),
|
||||
};
|
||||
|
||||
await GET(event as any);
|
||||
|
||||
expect(mockOptima.procurement.fetchMany).toHaveBeenCalledWith(
|
||||
"tok",
|
||||
1,
|
||||
{ search: "widget", includeInactive: true },
|
||||
20,
|
||||
);
|
||||
expect(mockJson).toHaveBeenCalledWith({
|
||||
data: [{ id: "item-1", name: "Widget" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on failure", async () => {
|
||||
mockOptima.procurement.fetchMany.mockRejectedValueOnce({
|
||||
status: 500,
|
||||
});
|
||||
|
||||
const event = {
|
||||
locals: { session: { accessToken: "tok" } },
|
||||
url: new URL("http://localhost/search?q=widget"),
|
||||
};
|
||||
|
||||
await expect(GET(event as any)).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user