frontend: real world movement

This commit is contained in:
Alex
2025-01-16 14:23:54 +01:00
parent 66ce257198
commit 11c55a17ea
46 changed files with 1277 additions and 563 deletions

View File

@@ -8,6 +8,7 @@
import { onMount } from "svelte";
import "$lib/utils/i18n.js";
// import "$lib/css/bootstrap-custom.scss";
/** @type {import('./$types').PageData} */
export let data;

View File

@@ -1,8 +1,17 @@
import { BASE_API_URI } from "$lib/utils/constants";
import { formatError, userDatesFromRFC3339 } from "$lib/utils/helpers";
import {
formatError,
userDatesFromRFC3339,
userDatesToRFC3339,
} from "$lib/utils/helpers";
import { fail, redirect } from "@sveltejs/kit";
import { toRFC3339 } from "$lib/utils/helpers";
/**
* @typedef {Object} UpdateData
* @property {Partial<App.Locals['user']>} user
*/
/** @type {import('./$types').PageServerLoad} */
export async function load({ locals, params }) {
// redirect user if not logged in
@@ -24,6 +33,7 @@ export const actions = {
updateUser: async ({ request, fetch, cookies, locals }) => {
let formData = await request.formData();
/** @type {App.Types['licenceCategory'][]} */
const licenceCategories = formData
.getAll("licence_categories[]")
.filter((value) => typeof value === "string")
@@ -34,11 +44,10 @@ export const actions = {
console.error("Failed to parse licence category:", value);
return null;
}
})
.filter(Boolean);
});
/** @type {Partial<App.Locals['user']>} */
const updateData = {
const userData = {
id: Number(formData.get("id")),
first_name: String(formData.get("first_name")),
last_name: String(formData.get("last_name")),
@@ -48,7 +57,7 @@ export const actions = {
address: String(formData.get("address")),
zip_code: String(formData.get("zip_code")),
city: String(formData.get("city")),
date_of_birth: toRFC3339(formData.get("birth_date")),
date_of_birth: toRFC3339(formData.get("date_of_birth")),
company: String(formData.get("company")),
profile_picture: String(formData.get("profile_picture")),
membership: {
@@ -64,9 +73,7 @@ export const actions = {
},
bank_account: {
id: Number(formData.get("bank_account_id")),
mandate_date_signed: toRFC3339(
String(formData.get("mandate_date_signed"))
),
mandate_date_signed: toRFC3339(formData.get("mandate_date_signed")),
bank: String(formData.get("bank")),
account_holder_name: String(formData.get("account_holder_name")),
iban: String(formData.get("iban")),
@@ -83,11 +90,17 @@ export const actions = {
licence_categories: licenceCategories,
},
};
// userDatesToRFC3339(userData);
/** @type {UpdateData} */
const updateData = { user: userData };
// Remove undefined or null properties
const cleanUpdateData = JSON.parse(
JSON.stringify(updateData),
(key, value) => (value !== null && value !== "" ? value : undefined)
);
console.dir(formData);
console.dir(cleanUpdateData);
const apiURL = `${BASE_API_URI}/backend/users/update/`;

View File

@@ -4,13 +4,6 @@ import { userDatesFromRFC3339, refreshCookie } from "$lib/utils/helpers";
/** @type {import('./$types').LayoutServerLoad} */
export async function load({ cookies, fetch, locals }) {
// if (locals.users) {
// return {
// users: locals.users,
// user: locals.user,
// };
// }
const jwt = cookies.get("jwt");
try {
// Fetch user data, subscriptions, and licence categories in parallel
@@ -27,7 +20,6 @@ export async function load({ cookies, fetch, locals }) {
}
const data = await response.json();
// Check if the server sent a new token
const newToken = response.headers.get("Set-Cookie");
refreshCookie(newToken, null);

View File

@@ -7,6 +7,130 @@ import { formatError, userDatesFromRFC3339 } from "$lib/utils/helpers";
import { fail, redirect } from "@sveltejs/kit";
import { toRFC3339 } from "$lib/utils/helpers";
/**
* Converts FormData to a nested object structure
* @param {FormData} formData - The FormData object to convert
* @returns {{ user: Partial<App.Locals['user']> }} Nested object representation of the form data
*/
function formDataToObject(formData) {
/** @type { Partial<App.Locals['user']> } */
const object = {};
console.log("Form data entries:");
for (const [key, value] of formData.entries()) {
console.log("Key:", key, "Value:", value);
}
for (const [key, value] of formData.entries()) {
/** @type {string[]} */
const keys = key.match(/\[([^\]]+)\]/g)?.map((k) => k.slice(1, -1)) || [
key,
];
console.log("Processed keys:", keys);
/** @type {Record<string, any>} */
let current = object;
console.log("Current object state:", JSON.stringify(current));
for (let i = 0; i < keys.length - 1; i++) {
/**
* Create nested object if it doesn't exist
* @type {Record<string, any>}
* @description Ensures proper nesting structure for user data fields
* @example
* // For input name="user[membership][status]"
* // Creates: { user: { membership: { status: value } } }
*/
current[keys[i]] = current[keys[i]] || {};
/**
* Move to the next level of the object
* @type {Record<string, any>}
*/
current = current[keys[i]];
}
const lastKey = keys[keys.length - 1];
if (lastKey.endsWith("[]")) {
/**
* Handle array fields (licence categories)
*/
const arrayKey = lastKey.slice(0, -2);
current[arrayKey] = current[arrayKey] || [];
current[arrayKey].push(value);
} else {
current[lastKey] = value;
}
}
return { user: object };
}
/**
* Processes the raw form data into the expected user data structure
* @param {{ user: Partial<App.Locals['user']> } } rawData - The raw form data object
* @returns {{ user: Partial<App.Locals['user']> }} Processed user data
*/
function processFormData(rawData) {
/** @type {{ user: Partial<App.Locals['user']> }} */
const processedData = {
user: {
id: Number(rawData.user.id) || 0,
status: Number(rawData.user.status),
role_id: Number(rawData.user.role_id),
first_name: String(rawData.user.first_name),
last_name: String(rawData.user.last_name),
email: String(rawData.user.email),
phone: String(rawData.user.phone || ""),
company: String(rawData.user.company || ""),
date_of_birth: toRFC3339(rawData.user.date_of_birth),
address: String(rawData.user.address || ""),
zip_code: String(rawData.user.zip_code || ""),
city: String(rawData.user.city || ""),
notes: String(rawData.user.notes || ""),
profile_picture: String(rawData.user.profile_picture || ""),
membership: {
id: Number(rawData.user.membership?.id) || 0,
status: Number(rawData.user.membership?.status),
start_date: toRFC3339(rawData.user.membership?.start_date),
end_date: toRFC3339(rawData.user.membership?.end_date),
parent_member_id:
Number(rawData.user.membership?.parent_member_id) || 0,
subscription_model: {
id: Number(rawData.user.membership?.subscription_model?.id) || 0,
name: String(rawData.user.membership?.subscription_model?.name) || "",
},
},
licence: {
id: Number(rawData.user.licence?.id) || 0,
status: Number(rawData.user.licence?.status),
licence_number: String(rawData.user.licence?.licence_number || ""),
issued_date: toRFC3339(rawData.user.licence?.issued_date),
expiration_date: toRFC3339(rawData.user.licence?.expiration_date),
country: String(rawData.user.licence?.country || ""),
licence_categories: rawData.user.licence?.licence_categories || [],
},
bank_account: {
id: Number(rawData.user.bank_account?.id) || 0,
account_holder_name: String(
rawData.user.bank_account?.account_holder_name || ""
),
bank: String(rawData.user.bank_account?.bank || ""),
iban: String(rawData.user.bank_account?.iban || ""),
bic: String(rawData.user.bank_account?.bic || ""),
mandate_reference: String(
rawData.user.bank_account?.mandate_reference || ""
),
mandate_date_signed: toRFC3339(
rawData.user.bank_account?.mandate_date_signed
),
},
},
};
return processedData;
}
/** @type {import('./$types').PageServerLoad} */
export async function load({ locals, params }) {
// redirect user if not logged in
@@ -28,77 +152,24 @@ export const actions = {
updateUser: async ({ request, fetch, cookies, locals }) => {
let formData = await request.formData();
const licenceCategories = formData
.getAll("licence_categories[]")
.filter((value) => typeof value === "string")
.map((value) => {
try {
return JSON.parse(value);
} catch (e) {
console.error("Failed to parse licence category:", value);
return null;
}
})
.filter(Boolean);
// Convert form data to nested object
const rawData = formDataToObject(formData);
const processedData = processFormData(rawData);
/** @type {Partial<App.Locals['user']>} */
const updateData = {
id: Number(formData.get("id")),
first_name: String(formData.get("first_name")),
last_name: String(formData.get("last_name")),
email: String(formData.get("email")),
phone: String(formData.get("phone")),
notes: String(formData.get("notes")),
address: String(formData.get("address")),
zip_code: String(formData.get("zip_code")),
city: String(formData.get("city")),
date_of_birth: toRFC3339(formData.get("birth_date")),
company: String(formData.get("company")),
profile_picture: String(formData.get("profile_picture")),
membership: {
id: Number(formData.get("membership_id")),
start_date: toRFC3339(formData.get("membership_start_date")),
end_date: toRFC3339(formData.get("membership_end_date")),
status: Number(formData.get("membership_status")),
parent_member_id: Number(formData.get("parent_member_id")),
subscription_model: {
id: Number(formData.get("subscription_model_id")),
name: String(formData.get("subscription_model_name")),
},
},
bank_account: {
id: Number(formData.get("bank_account_id")),
mandate_date_signed: toRFC3339(
String(formData.get("mandate_date_signed"))
),
bank: String(formData.get("bank")),
account_holder_name: String(formData.get("account_holder_name")),
iban: String(formData.get("iban")),
bic: String(formData.get("bic")),
mandate_reference: String(formData.get("mandate_reference")),
},
licence: {
id: Number(formData.get("drivers_licence_id")),
status: Number(formData.get("licence_status")),
licence_number: String(formData.get("licence_number")),
issued_date: toRFC3339(formData.get("issued_date")),
expiration_date: toRFC3339(formData.get("expiration_date")),
country: String(formData.get("country")),
licence_categories: licenceCategories,
},
};
// Remove undefined or null properties
const cleanUpdateData = JSON.parse(
JSON.stringify(updateData),
JSON.stringify(processedData),
(key, value) => (value !== null && value !== "" ? value : undefined)
);
console.dir(formData);
console.dir(cleanUpdateData);
const apiURL = `${BASE_API_URI}/backend/users/update/`;
console.dir(processedData.user.membership);
const isCreating = !processedData.user.id || processedData.user.id === 0;
console.log("Is creating: ", isCreating);
const apiURL = `${BASE_API_URI}/backend/users/update`;
/** @type {RequestInit} */
const requestUpdateOptions = {
method: "PATCH",
const requestOptions = {
method: isCreating ? "POST" : "PATCH",
credentials: "include",
headers: {
"Content-Type": "application/json",
@@ -106,7 +177,8 @@ export const actions = {
},
body: JSON.stringify(cleanUpdateData),
};
const res = await fetch(apiURL, requestUpdateOptions);
const res = await fetch(apiURL, requestOptions);
if (!res.ok) {
const response = await res.json();
@@ -115,6 +187,7 @@ export const actions = {
}
const response = await res.json();
console.log("Server success response:", response);
locals.user = response;
userDatesFromRFC3339(locals.user);
throw redirect(303, `/auth/about/${response.id}`);

View File

@@ -1,39 +1,36 @@
<!-- - Create a table or list view of all users.
- Implement a search or filter functionality.
- Add a modal component for editing user details (reuse the modal from about/[id]). -->
<script>
import { onMount } from "svelte";
import Modal from "$lib/components/Modal.svelte";
import UserEditForm from "$lib/components/UserEditForm.svelte";
import { Styles } from "@sveltestrap/sveltestrap";
import { t } from "svelte-i18n";
import { page } from "$app/stores";
/** @type {import('./$types').ActionData} */
export let form;
$: ({ user, users, licence_categories, subscriptions } = $page.data);
$: ({
user,
users = [],
licence_categories = [],
subscriptions = [],
payments = [],
} = $page.data);
/** @type(App.Locals['user'] | null) */
let activeSection = "users";
/** @type{App.Locals['user'] | null} */
let selectedUser = null;
let showModal = false;
/**
* Opens the edit modal for the selected user.
* @param {App.Locals['user']} user The user to edit.
* @param {App.Locals['user'] | null} user The user to edit.
*/
const openEditModal = (user) => {
selectedUser = user;
console.dir(selectedUser);
showModal = true;
};
/**
* Opens the delete modal for the selected user.
* @param {App.Locals['user']} user The user to edit.
*/
const openDelete = (user) => {};
const close = () => {
showModal = false;
selectedUser = null;
@@ -41,52 +38,329 @@
form.errors = undefined;
}
};
/**
* sets the active admin section for display
* @param {string} section The new active section.
*/
const setActiveSection = (section) => {
activeSection = section;
};
</script>
<div class="admin-users-page">
<h1>{$t("user.management")}</h1>
<div class="container">
<div class="layout">
<!-- Sidebar -->
<nav class="sidebar">
<ul class="nav-list">
<li>
<button
class="nav-link {activeSection === 'users' ? 'active' : ''}"
on:click={() => setActiveSection("users")}
>
<i class="fas fa-users" />
<span class="nav-badge">{users.length}</span>
{$t("users")}
</button>
</li>
<li>
<button
class="nav-link {activeSection === 'subscriptions' ? 'active' : ''}"
on:click={() => setActiveSection("subscriptions")}
>
<i class="fas fa-clipboard-list" />
<span class="nav-badge">{subscriptions.length}</span>
{$t("subscriptions")}
</button>
</li>
<li>
<button
class="nav-link {activeSection === 'payments' ? 'active' : ''}"
on:click={() => setActiveSection("payments")}
>
<i class="fas fa-credit-card" />
{$t("payments")}
</button>
</li>
</ul>
</nav>
<div class="search-filter" />
<table class="user-table">
<thead>
<tr>
<th>{$t("user.id")}</th>
<th>{$t("name")}</th>
<th>{$t("email")}</th>
<th>{$t("status")}</th>
<th>{$t("actions")}</th>
</tr>
</thead>
<tbody>
{#each users as user}
<tr>
<td>{user.id}</td>
<td>{user.first_name} {user.last_name}</td>
<td>{user.email}</td>
<td>{$t("userStatus." + user.status)}</td>
<td>
<button on:click={() => openEditModal(user)}>{$t("edit")}</button>
<button on:click={() => openDelete(user)}>{$t("delete")}</button>
</td>
</tr>{/each}
</tbody>
</table>
<div class="pagination" />
{#if showModal}
<Modal on:close={close}>
<UserEditForm
{form}
user={selectedUser}
{subscriptions}
{licence_categories}
on:cancel={close}
/>
</Modal>
{/if}
<!-- Main Content -->
<main class="main-content">
{#if activeSection === "users"}
<div class="section-header">
<h2>{$t("users")}</h2>
<button class="btn primary" on:click={() => openEditModal(null)}>
<i class="fas fa-plus" />
{$t("add_new")}
</button>
</div>
<div class="accordion">
{#each users as user}
<details class="accordion-item">
<summary class="accordion-header">
{user.first_name}
{user.last_name} - {user.email}
</summary>
<div class="accordion-content">
<table class="table">
<tbody>
<tr>
<th>{$t("user.id")}</th>
<td>{user.id}</td>
</tr>
<tr>
<th>{$t("name")}</th>
<td>{user.first_name} {user.last_name}</td>
</tr>
<tr>
<th>{$t("email")}</th>
<td>{user.email}</td>
</tr>
<tr>
<th>{$t("status")}</th>
<td>{$t("userStatus." + user.status)}</td>
</tr>
</tbody>
</table>
<div class="button-group">
<button
class="btn primary"
on:click={() => openEditModal(user)}
>
<i class="fas fa-edit" />
{$t("edit")}
</button>
<button class="btn danger">
<i class="fas fa-trash" />
{$t("delete")}
</button>
</div>
</div>
</details>
{/each}
</div>
{:else if activeSection === "subscriptions"}
<div class="section-header">
<h2>{$t("subscriptions")}</h2>
<button class="btn primary" on:click={() => openEditModal(null)}>
<i class="fas fa-plus" />
{$t("add_new")}
</button>
</div>
<div class="accordion">
{#each subscriptions as subscription}
<details class="accordion-item">
<summary class="accordion-header">
{subscription.name}
</summary>
<div class="accordion-content">
<table class="table">
<tbody>
<tr>
<th>{$t("monthly_fee")}</th>
<td
>{subscription.monthly_fee !== -1
? subscription.monthly_fee + "€"
: "-"}</td
>
</tr>
<tr>
<th>{$t("hourly_rate")}</th>
<td
>{subscription.hourly_rate !== -1
? subscription.hourly_rate + "€"
: "-"}</td
>
</tr>
<tr>
<th>{$t("included_hours_per_year")}</th>
<td>{subscription.included_hours_per_year || 0}</td>
</tr>
<tr>
<th>{$t("included_hours_per_month")}</th>
<td>{subscription.included_hours_per_month || 0}</td>
</tr>
<tr>
<th>{$t("details")}</th>
<td>{subscription.details || "-"}</td>
</tr>
<tr>
<th>{$t("conditions")}</th>
<td>{subscription.conditions || "-"}</td>
</tr>
</tbody>
</table>
</div>
</details>
{/each}
</div>
{:else if activeSection === "payments"}
<h2>{$t("payments")}</h2>
<div class="accordion">
{#each payments as payment}
<details class="accordion-item">
<summary class="accordion-header">
Payment #{payment.id} - {payment.amount}
</summary>
<div class="accordion-content">
<table class="table">
<tbody>
<tr>
<th>Date</th>
<td>{new Date(payment.date).toLocaleDateString()}</td>
</tr>
<tr>
<th>Status</th>
<td>{payment.status}</td>
</tr>
</tbody>
</table>
</div>
</details>
{/each}
</div>
{/if}
</main>
</div>
</div>
{#if showModal}
<Modal on:close={close}>
<UserEditForm
{form}
user={selectedUser}
{subscriptions}
{licence_categories}
on:cancel={close}
/>
</Modal>
{/if}
<style>
.container {
width: 100%;
height: 100%;
padding: 0 1rem;
color: white;
}
.layout {
display: grid;
grid-template-columns: 250px 1fr;
gap: 2rem;
height: 100%;
width: inherit;
}
.sidebar {
width: 250px;
min-height: 600px;
background: #2f2f2f;
border-right: 1px solid #494848;
}
.nav-list {
list-style: none;
padding: 0;
margin: 0;
}
.nav-link {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem 1rem;
border: none;
background: none;
text-align: left;
cursor: pointer;
color: #9b9b9b;
text-transform: uppercase;
font-weight: 500;
letter-spacing: 1px;
transition: color 0.3s ease-in-out;
}
.nav-link:hover {
background: #fdfff5;
}
.nav-link.active {
background: #494848;
color: white;
}
.main-content {
padding: 2rem;
min-width: 75%;
}
.accordion-item {
border: none;
background: #2f2f2f;
margin-bottom: 0.5rem;
}
.accordion-header {
padding: 1rem;
cursor: pointer;
font-family: "Roboto Mono", monospace;
color: white;
}
.accordion-content {
padding: 1rem;
background: #494848;
}
.table {
width: 100%;
border-collapse: collapse;
font-family: "Roboto Mono", monospace;
}
.table th,
.table td {
padding: 0.75rem;
border-bottom: 1px solid #2f2f2f;
text-align: left;
}
.table th {
color: #9b9b9b;
}
.table td {
color: white;
}
@media (max-width: 680px) {
.layout {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
width: 100%;
padding: 1rem 0;
}
.main-content {
margin-left: 0;
margin-top: 120px;
}
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.section-header h2 {
margin: 0;
}
</style>

View File

@@ -0,0 +1,94 @@
<!-- - Create a table or list view of all users.
- Implement a search or filter functionality.
- Add a modal component for editing user details (reuse the modal from about/[id]). -->
<script>
import { onMount } from "svelte";
import Modal from "$lib/components/Modal.svelte";
import UserEditForm from "$lib/components/UserEditForm.svelte";
import { t } from "svelte-i18n";
import { page } from "$app/stores";
import "static/css/bootstrap.min.css";
import "static/js/bootstrapv5/bootstrap.bundle.min.js";
/** @type {import('./$types').ActionData} */
export let form;
$: ({ user, users, licence_categories, subscriptions } = $page.data);
/** @type(App.Locals['user'] | null) */
let selectedUser = null;
let showModal = false;
/**
* Opens the edit modal for the selected user.
* @param {App.Locals['user']} user The user to edit.
*/
const openEditModal = (user) => {
selectedUser = user;
showModal = true;
};
/**
* Opens the delete modal for the selected user.
* @param {App.Locals['user']} user The user to edit.
*/
const openDelete = (user) => {};
const close = () => {
showModal = false;
selectedUser = null;
if (form) {
form.errors = undefined;
}
};
</script>
<div class="admin-users-page">
<h1>{$t("user.management")}</h1>
<div class="search-filter" />
<table class="user-table">
<thead>
<tr>
<th>{$t("user.id")}</th>
<th>{$t("name")}</th>
<th>{$t("email")}</th>
<th>{$t("status")}</th>
<th>{$t("actions")}</th>
</tr>
</thead>
<tbody>
{#each users as user}
<tr>
<td>{user.id}</td>
<td>{user.first_name} {user.last_name}</td>
<td>{user.email}</td>
<td>{$t("userStatus." + user.status)}</td>
<td>
<button on:click={() => openEditModal(user)}>{$t("edit")}</button>
<button on:click={() => openDelete(user)}>{$t("delete")}</button>
</td>
</tr>{/each}
</tbody>
</table>
<div class="pagination" />
{#if showModal}
<Modal on:close={close}>
<UserEditForm
{form}
user={selectedUser}
{subscriptions}
{licence_categories}
on:cancel={close}
/>
</Modal>
{/if}
</div>
<style>
</style>

View File

@@ -5,7 +5,9 @@ import { fail, redirect } from "@sveltejs/kit";
/** @type {import('./$types').PageServerLoad} */
export async function load({ locals }) {
// redirect user if logged in
console.log("loading login page");
if (locals.user) {
console.log("user is logged in");
throw redirect(302, "/");
}
}
@@ -20,11 +22,11 @@ export const actions = {
* @returns Error data or redirects user to the home page or the previous page
*/
login: async ({ request, fetch, cookies }) => {
console.log("login action called");
const data = await request.formData();
const email = String(data.get("email"));
const password = String(data.get("password"));
const next = String(data.get("next"));
/** @type {RequestInit} */
const requestInitOptions = {
method: "POST",
@@ -37,9 +39,7 @@ export const actions = {
password: password,
}),
};
const res = await fetch(`${BASE_API_URI}/users/login/`, requestInitOptions);
const res = await fetch(`${BASE_API_URI}/users/login`, requestInitOptions);
console.log("Login response status:", res.status);
console.log("Login response headers:", Object.fromEntries(res.headers));

View File

@@ -32,8 +32,8 @@
{#each form?.errors as error (error.id)}
<h4
class="step-subtitle warning"
in:receive={{ key: error.id }}
out:send={{ key: error.id }}
in:receive|global={{ key: error.id }}
out:send|global={{ key: error.id }}
>
{$t(error.key)}
</h4>