Companys are now listing on the companies page.

This commit is contained in:
2026-02-13 17:01:42 -06:00
parent 1a45f708ec
commit 6b176196d3
9 changed files with 304 additions and 250 deletions
+12 -7
View File
@@ -1,13 +1,18 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
namespace App {
// interface Error {}
interface Locals {
session?: {
accessToken: string;
refreshToken: string;
};
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+6 -1
View File
@@ -1,4 +1,4 @@
import { user } from "$lib";
import { api, user } from "$lib";
import { redirect, type Handle } from "@sveltejs/kit";
import { access } from "fs";
import { a } from "vitest/dist/chunks/suite.d.FvehnV49.js";
@@ -7,6 +7,11 @@ export const handle: Handle = async ({ event, resolve }) => {
const accessToken = event.cookies.get("access_token");
const refreshToken = event.cookies.get("refresh_token");
event.locals.session = {
accessToken: accessToken || "",
refreshToken: refreshToken || "",
};
if (event.url.pathname === "/logout") {
event.cookies.delete("access_token", { path: "/" });
event.cookies.delete("refresh_token", { path: "/" });
+3 -166
View File
@@ -1,172 +1,9 @@
import { getRequestEvent } from "$app/server";
import { redirect } from "@sveltejs/kit";
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from "axios";
import { PUBLIC_API_URL } from "$env/static/public";
import axios, { AxiosInstance } from "axios";
type GetAccessToken = () => string | null | Promise<string | null>;
type SetAccessToken = (token: string | null) => void;
type RefreshHandler = () => Promise<string>;
type LogoutHandler = () => void;
interface Handlers {
getAccessToken?: GetAccessToken;
setAccessToken?: SetAccessToken;
refreshHandler?: RefreshHandler;
onRefreshFailed?: LogoutHandler;
}
const event = getRequestEvent();
const api: AxiosInstance = axios.create({
baseURL: process.env.API_URL || "",
withCredentials: true,
baseURL: PUBLIC_API_URL || "",
});
let handlers: Handlers = {
getAccessToken() {
if (!event) return null;
const token = event.cookies.get("authToken");
return token || null;
},
setAccessToken(token: string | null) {
if (!event) return;
if (token) {
event.cookies.set("authToken", token, { httpOnly: true, path: "/" });
}
},
refreshHandler: async () => {
if (!event) throw new Error("No request event available");
const refreshToken = event.cookies.get("refreshToken");
if (!refreshToken) throw new Error("No refresh token available");
const response = await api.post(
"/auth/refresh",
{},
{
headers: {
"x-refresh-token": `${refreshToken}`,
},
},
);
const newAuthToken = response.data.data.authToken;
const newRefreshToken = response.data.data.refreshToken;
// Update cookies
event.cookies.set("authToken", newAuthToken, { httpOnly: true, path: "/" });
event.cookies.set("refreshToken", newRefreshToken, {
httpOnly: true,
path: "/",
});
return newAuthToken;
},
onRefreshFailed: () => {
if (!event) return;
event.cookies.delete("authToken", { path: "/" });
event.cookies.delete("refreshToken", { path: "/" });
return redirect(303, "/");
},
};
let isRefreshing = false;
let refreshPromise: Promise<string> | null = null;
const queue: {
resolve: (value?: unknown) => void;
reject: (err: unknown) => void;
config: AxiosRequestConfig;
}[] = [];
export function registerAuthHandlers(h: Handlers) {
handlers = { ...handlers, ...h };
}
async function getToken(): Promise<string | null> {
if (!handlers.getAccessToken) return null;
return handlers.getAccessToken();
}
function setToken(token: string | null) {
if (!handlers.setAccessToken) return;
handlers.setAccessToken(token);
}
function enqueueRequest(p: {
resolve: (value?: unknown) => void;
reject: (err: unknown) => void;
config: AxiosRequestConfig;
}) {
queue.push(p);
}
function processQueue(error: unknown, token: string | null = null) {
while (queue.length) {
const p = queue.shift();
if (!p) continue;
if (error) p.reject(error);
else {
if (token && p.config.headers) {
(p.config.headers as Record<string, string>)["Authorization"] =
`Bearer ${token}`;
}
p.resolve(api.request(p.config));
}
}
}
api.interceptors.request.use(
async (config) => {
const token = await getToken();
if (token && config.headers) {
(config.headers as Record<string, string>)["Authorization"] =
`Bearer ${token}`;
}
return config;
},
(err) => Promise.reject(err),
);
api.interceptors.response.use(
(res) => res,
async (
error: AxiosError & { config?: AxiosRequestConfig & { _retry?: boolean } },
) => {
const originalConfig = error.config;
if (!originalConfig) return Promise.reject(error);
const status = error.response?.status;
if (status === 401 && !originalConfig._retry) {
originalConfig._retry = true;
if (!handlers.refreshHandler) {
handlers.onRefreshFailed?.();
return Promise.reject(error);
}
if (!isRefreshing) {
isRefreshing = true;
refreshPromise = handlers.refreshHandler!()
.then((newToken) => {
setToken(newToken);
processQueue(null, newToken);
return newToken;
})
.catch((err) => {
processQueue(err);
handlers.onRefreshFailed?.();
throw err;
})
.finally(() => {
isRefreshing = false;
refreshPromise = null;
});
}
return new Promise((resolve, reject) => {
enqueueRequest({ resolve, reject, config: originalConfig });
});
}
return Promise.reject(error);
},
);
export { api };
export default api;
+16
View File
@@ -0,0 +1,16 @@
import api from "./axios";
export const company = {
async fetch() {},
async fetchMany(accessToken: string, page: number = 1) {
const companies = await api.get("/v1/company/companies", {
params: {
page,
},
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return companies.data;
},
};
+2 -4
View File
@@ -1,10 +1,8 @@
// place files you want to import through the `$lib` alias in this folder.
/**
* @TODO MAKE THIS WORK
*/
//export * from "./axios";
export * from "./axios";
export * from "./user";
export * from "./companies";
/**
* @TODO
+2
View File
@@ -28,6 +28,8 @@ export const user = {
return refreshedTokens;
},
fetchInfo() {},
logout(event: RequestEvent) {
if (!event) return;
+12
View File
@@ -0,0 +1,12 @@
import { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ locals }) => {
// WARNING: returning tokens to the client exposes them to JavaScript.
// Prefer keeping tokens httpOnly and proxying requests via server endpoints.
return {
session: {
accessToken: locals.session?.accessToken ?? null,
refreshToken: locals.session?.refreshToken ?? null,
},
};
};
+195 -72
View File
@@ -1,32 +1,94 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { company } from "$lib";
import LoadingSpinner from "$lib/../components/LoadingSpinner.svelte";
// Dummy data - 100 companies for pagination demo
const dummyCompanies = Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
name: `Company ${i + 1}`,
cw_CompanyId: `CW-${String(i + 1).padStart(5, "0")}`,
cw_Identifier: `ID-${String(i + 1).padStart(5, "0")}`,
}));
export let data;
interface Company {
id: string;
name: string;
cw_CompanyId: number;
cw_Identifier: string;
createdAt: string;
updatedAt: string;
}
interface ApiResponse {
status: number;
message: string;
data: Company[];
successful: boolean;
meta: {
timestamp: number;
pagination: {
previousPage: number | null;
currentPage: number;
nextPage: number | null;
totalPages: number;
totalRecords: number;
listedRecords: number;
};
};
}
let companies: Company[] = [];
let totalPages = 0;
let currentPage = 1;
let totalRecords = 0;
let isLoading = true;
let error: string | null = null;
let searchQuery = "";
const itemsPerPage = 30; // 3 columns x 10 rows
$: totalPages = Math.ceil(dummyCompanies.length / itemsPerPage);
$: startIndex = (currentPage - 1) * itemsPerPage;
$: endIndex = startIndex + itemsPerPage;
$: displayedCompanies = dummyCompanies.slice(startIndex, endIndex);
const itemsPerPage = 30;
async function loadCompanies(page: number = 1) {
isLoading = true;
error = null;
try {
const response = await company.fetchMany(
data.session.accessToken || "",
page,
);
if (response && response.data && Array.isArray(response.data)) {
companies = response.data;
totalRecords =
response.meta?.pagination?.totalRecords || response.data.length;
totalPages =
response.meta?.pagination?.totalPages ||
Math.ceil(companies.length / itemsPerPage);
currentPage = page;
} else {
error = "Failed to load companies";
}
} catch (err) {
console.error("Failed to fetch companies:", err);
error = "Error loading companies. Please try again.";
} finally {
isLoading = false;
}
}
$: displayedCompanies = companies.filter(
(c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.cw_Identifier.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.cw_CompanyId.toString().includes(searchQuery),
);
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
loadCompanies(page);
}
}
function signOut() {
goto("/logout");
}
// Load companies on component mount
loadCompanies();
</script>
<svelte:head>
@@ -46,68 +108,108 @@
<main class="container">
<section class="hero">
<h2>Company Directory</h2>
<p>Browse all companies. Total: {dummyCompanies.length} companies</p>
{#if isLoading}
<LoadingSpinner loading={true} />
{:else if error}
<p class="error">{error}</p>
{:else}
<p>Browse all companies. Total: {totalRecords} companies</p>
{/if}
</section>
<section class="search-section">
<input
type="text"
placeholder="Search companies by name, ID, or identifier..."
bind:value={searchQuery}
class="search-bar"
/>
</section>
<section class="companies-grid">
{#each displayedCompanies as company (company.id)}
<article class="company-card">
<h3>{company.name}</h3>
<dl>
<dt>ID</dt>
<dd>{company.id}</dd>
<dt>CW Company ID</dt>
<dd>{company.cw_CompanyId}</dd>
<dt>CW Identifier</dt>
<dd>{company.cw_Identifier}</dd>
</dl>
<a href="/company/{company.id}" class="view-link">View Details</a>
</article>
{/each}
</section>
{#if totalPages > 1}
<section class="pagination">
<button
on:click={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
class="pagination-btn"
>
← Previous
</button>
<div class="page-numbers">
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
<button
on:click={() => goToPage(page)}
class="page-number {page === currentPage ? 'active' : ''}"
>
{page}
</button>
{/each}
</div>
<button
on:click={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
class="pagination-btn"
>
Next →
</button>
<span class="page-info">
Page {currentPage} of {totalPages}
</span>
{#if !isLoading && !error}
<section class="search-section">
<input
type="text"
placeholder="Search companies by name, ID, or identifier..."
bind:value={searchQuery}
class="search-bar"
/>
{#if searchQuery}
<p class="search-results">
Found {displayedCompanies.length} company/companies matching "{searchQuery}"
</p>
{/if}
</section>
{#if displayedCompanies.length > 0}
<section class="companies-grid">
{#each displayedCompanies as comp (comp.id)}
<article class="company-card">
<h3>{comp.name}</h3>
<dl>
<dt>CW Company ID</dt>
<dd>{comp.cw_CompanyId}</dd>
<dt>CW Identifier</dt>
<dd>{comp.cw_Identifier}</dd>
<dt>Created</dt>
<dd>{new Date(comp.createdAt).toLocaleDateString()}</dd>
</dl>
<a href="/company/{comp.id}" class="view-link">View Details</a>
</article>
{/each}
</section>
{:else}
<section class="companies-grid">
<p class="no-results">No companies found</p>
</section>
{/if}
{#if totalPages > 1 && !searchQuery}
<section class="pagination">
<button
on:click={() => goToPage(1)}
disabled={currentPage === 1}
class="pagination-btn"
>
⟨⟨ First
</button>
<button
on:click={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
class="pagination-btn"
>
← Previous
</button>
<div class="page-numbers">
{#each Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
const startPage = Math.max(1, currentPage - 2);
return startPage + i;
}) as page}
{#if page <= totalPages}
<button
on:click={() => goToPage(page)}
class="page-number {page === currentPage ? 'active' : ''}"
>
{page}
</button>
{/if}
{/each}
</div>
<button
on:click={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
class="pagination-btn"
>
Next →
</button>
<button
on:click={() => goToPage(totalPages)}
disabled={currentPage === totalPages}
class="pagination-btn"
>
Last ⟩⟩
</button>
<span class="page-info">
Page {currentPage} of {totalPages}
</span>
</section>
{/if}
{/if}
</main>
@@ -169,6 +271,27 @@
color: #6b7280;
}
.error {
color: #dc2626;
padding: 1rem;
background: #fee2e2;
border-radius: 6px;
margin: 0;
}
.search-results {
color: #6b7280;
margin-top: 0.5rem;
font-size: 0.9rem;
}
.no-results {
color: #9ca3af;
text-align: center;
padding: 2rem;
grid-column: 1 / -1;
}
.search-section {
margin: 1.5rem 0;
}