Compare commits

...

2 Commits

Author SHA1 Message Date
Alex
183e4da7f4 Backend:Real world movement 2025-01-16 14:24:21 +01:00
Alex
11c55a17ea frontend: real world movement 2025-01-16 14:23:54 +01:00
51 changed files with 1311 additions and 591 deletions

View File

@@ -76,6 +76,9 @@ declare global {
subscriptions: Subscription[]; subscriptions: Subscription[];
licence_categories: LicenceCategory[]; licence_categories: LicenceCategory[];
} }
interface Types {
licenceCategory: LicenceCategory;
}
// interface PageData {} // interface PageData {}
// interface Platform {} // interface Platform {}
} }

View File

@@ -1,18 +1,26 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="de">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Poiret+One&family=Quicksand:wght@400;500;600;700&display=swap" href="https://fonts.googleapis.com/css2?family=Poiret+One&family=Quicksand:wght@400;500;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
%sveltekit.head% <link
</head> rel="stylesheet"
<body data-sveltekit-preload-data="hover"> href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
<div style="display: contents">%sveltekit.body%</div> />
</body> <!-- <link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
rel="stylesheet"
/> -->
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html> </html>

View File

@@ -3,8 +3,10 @@ import { refreshCookie, userDatesFromRFC3339 } from "$lib/utils/helpers";
/** @type {import('@sveltejs/kit').Handle} */ /** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) { export async function handle({ event, resolve }) {
console.log("Hook started", event.url.pathname);
if (event.locals.user) { if (event.locals.user) {
// if there is already a user in session load page as normal // if there is already a user in session load page as normal
console.log("user is logged in");
return await resolve(event); return await resolve(event);
} }
@@ -52,8 +54,6 @@ export async function handle({ event, resolve }) {
event.locals.user = data.user; event.locals.user = data.user;
event.locals.subscriptions = subscriptionsData.subscriptions; event.locals.subscriptions = subscriptionsData.subscriptions;
event.locals.licence_categories = licence_categoriesData.licence_categories; event.locals.licence_categories = licence_categoriesData.licence_categories;
// console.log("hooks.server: Printing locals:");
// console.dir(event.locals);
// load page as normal // load page as normal
return await resolve(event); return await resolve(event);

View File

@@ -28,7 +28,7 @@
<div class="modal-background"> <div class="modal-background">
<div <div
transition:modal={{ duration: 1000 }} transition:modal|global={{ duration: 1000 }}
class="modal" class="modal"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"

View File

@@ -8,7 +8,7 @@
</script> </script>
{#key key} {#key key}
<div in:slide={{ duration, delay: duration }} out:slide={{ duration }}> <div in:slide|global={{ duration, delay: duration }} out:slide|global={{ duration }}>
<slot /> <slot />
</div> </div>
{/key} {/key}

View File

@@ -1,6 +1,7 @@
<script> <script>
import InputField from "$lib/components/InputField.svelte"; import InputField from "$lib/components/InputField.svelte";
import SmallLoader from "$lib/components/SmallLoader.svelte"; import SmallLoader from "$lib/components/SmallLoader.svelte";
import { createEventDispatcher } from "svelte";
import { applyAction, enhance } from "$app/forms"; import { applyAction, enhance } from "$app/forms";
import { receive, send } from "$lib/utils/helpers"; import { receive, send } from "$lib/utils/helpers";
import { t } from "svelte-i18n"; import { t } from "svelte-i18n";
@@ -11,57 +12,85 @@
/** @type {App.Locals['subscriptions']}*/ /** @type {App.Locals['subscriptions']}*/
export let subscriptions; export let subscriptions;
/** @type {App.Locals['user']} */
const blankUser = {
id: 0,
email: "",
first_name: "",
last_name: "",
phone: "",
address: "",
zip_code: "",
city: "",
company: "",
date_of_birth: "",
notes: "",
profile_picture: "",
payment_status: 0,
status: 1,
role_id: 0,
membership: {
id: 0,
start_date: "",
end_date: "",
status: 3,
parent_member_id: 0,
subscription_model: {
id: 0,
name: "",
},
},
licence: {
id: 0,
status: 1,
licence_number: "",
issued_date: "",
expiration_date: "",
country: "",
licence_categories: [],
},
bank_account: {
id: 0,
mandate_date_signed: "",
bank: "",
account_holder_name: "",
iban: "",
bic: "",
mandate_reference: "",
},
};
/** @type {App.Locals['user'] | null} */ /** @type {App.Locals['user'] | null} */
export let user; export let user;
if (user == null) {
user = { /** @type {App.Locals['user'] } */
id: 0, let localUser;
email: "",
first_name: "", $: {
last_name: "", if (user !== undefined && !localUser) {
phone: "", localUser =
address: "", user === null
zip_code: "", ? { ...blankUser }
city: "", : {
company: "", ...user,
date_of_birth: "", licence: user.licence || blankUser.licence,
notes: "", membership: user.membership || blankUser.membership,
profile_picture: "", bank_account: user.bank_account || blankUser.bank_account,
payment_status: 0, };
status: 1, }
role_id: 0,
membership: {
id: 0,
start_date: "",
end_date: "",
status: 3,
parent_member_id: 0,
subscription_model: {
id: 0,
name: "",
},
},
licence: {
id: 0,
status: 1,
licence_number: "",
issued_date: "",
expiration_date: "",
country: "",
licence_categories: [],
},
bank_account: {
id: 0,
mandate_date_signed: "",
bank: "",
account_holder_name: "",
iban: "",
bic: "",
mandate_reference: "",
},
};
} }
$: isNewUser = user === null;
$: isLoading = user === undefined;
$: {
console.log("incomingUser:", user);
}
// Add debug logging for user
$: {
console.log("processed user:", user);
}
/** @type {App.Locals['licence_categories']} */ /** @type {App.Locals['licence_categories']} */
export let licence_categories; export let licence_categories;
@@ -91,6 +120,7 @@
{ value: 5, label: $t("userStatus.5"), color: "#FF4646" }, // Red for "Deaktiviert" { value: 5, label: $t("userStatus.5"), color: "#FF4646" }, // Red for "Deaktiviert"
]; ];
const dispatch = createEventDispatcher();
const TABS = ["profile", "licence", "membership", "bankaccount"]; const TABS = ["profile", "licence", "membership", "bankaccount"];
let activeTab = TABS[0]; let activeTab = TABS[0];
@@ -106,7 +136,7 @@
})); }));
$: selectedSubscriptionModel = $: selectedSubscriptionModel =
subscriptions.find( subscriptions.find(
(sub) => sub?.id === user.membership?.subscription_model.id (sub) => sub?.id === localUser.membership?.subscription_model.id
) || null; ) || null;
/** /**
@@ -142,374 +172,386 @@
} }
/** @type {import('../../routes/auth/about/[id]/$types').SubmitFunction} */ /** @type {import('../../routes/auth/about/[id]/$types').SubmitFunction} */
const handleUpdate = async ({ form, formData, action, cancel }) => { const handleUpdate = async ({ formData, action, cancel }) => {
isUpdating = true; isUpdating = true;
return async ({ result }) => { return async ({ result }) => {
isUpdating = false; isUpdating = false;
if (result.type === "success" || result.type === "redirect") { if (result.type === "success" || result.type === "redirect") {
close(); close();
} else {
document
.querySelector(".modal .container")
?.scrollTo({ top: 0, behavior: "smooth" });
} }
await applyAction(result); await applyAction(result);
}; };
}; };
</script> </script>
<form {#if isLoading}
class="content" <SmallLoader width={30} message={$t("loading.user_data")} />
action="?/updateUser" {:else if localUser}
method="POST" <form
use:enhance={handleUpdate} class="content"
> action="?/updateUser"
<input name="id" type="number" hidden bind:value={user.id} /> method="POST"
<h1 class="step-title" style="text-align: center;">{$t("user.edit")}</h1> use:enhance={handleUpdate}
{#if form?.success} >
<h4 <input name="user[id]" type="hidden" bind:value={localUser.id} />
class="step-subtitle warning" <h1 class="step-title" style="text-align: center;">{$t("user.edit")}</h1>
in:receive={{ key: Math.floor(Math.random() * 100) }} {#if form?.success}
out:send={{ key: Math.floor(Math.random() * 100) }}
>
Um einen fehlerhaften upload Ihres Bildes zu vermeiden, clicke bitte auf
den "Update" Button unten.
</h4>
{/if}
{#if form?.errors}
{#each form?.errors as error (error.id)}
<h4 <h4
class="step-subtitle warning" class="step-subtitle warning"
in:receive={{ key: error.id }} in:receive|global={{ key: Math.floor(Math.random() * 100) }}
out:send={{ key: error.id }} out:send|global={{ key: Math.floor(Math.random() * 100) }}
> >
{$t(error.field) + ": " + $t(error.key)} Um einen fehlerhaften upload Ihres Bildes zu vermeiden, clicke bitte auf
den "Update" Button unten.
</h4> </h4>
{/each} {/if}
{/if} {#if form?.errors}
{#each form?.errors as error (error.id)}
<h4
class="step-subtitle warning"
in:receive|global={{ key: error.id }}
out:send|global={{ key: error.id }}
>
{$t(error.field) + ": " + $t(error.key)}
</h4>
{/each}
{/if}
<input <input
type="hidden" type="hidden"
hidden hidden
name="profile_picture" name="user[profile_picture]"
bind:value={user.profile_picture} bind:value={localUser.profile_picture}
/>
<div class="button-container">
{#each TABS as tab}
<button
type="button"
class="button-dark"
class:active={activeTab === tab}
on:click={() => setActiveTab(tab)}
>
{$t(tab)}
</button>
{/each}
</div>
<div
class="tab-content"
style="display: {activeTab === 'profile' ? 'block' : 'none'}"
>
<InputField
name="status"
type="select"
label={$t("status")}
bind:value={user.status}
options={userStatusOptions}
/> />
{#if user.role_id === 8}
<div class="button-container">
{#each TABS as tab}
<button
type="button"
class="button-dark"
class:active={activeTab === tab}
on:click={() => setActiveTab(tab)}
>
{$t(tab)}
</button>
{/each}
</div>
<div
class="tab-content"
style="display: {activeTab === 'profile' ? 'block' : 'none'}"
>
<InputField <InputField
name="role_id" name="user[status]"
type="select" type="select"
label={$t("user.role")} label={$t("status")}
bind:value={user.role_id} bind:value={localUser.status}
options={userRoleOptions} options={userStatusOptions}
/> />
{/if} {#if localUser.role_id === 8}
<InputField <InputField
name="password" name="user[role_id]"
type="password" type="select"
label={$t("password")} label={$t("user.role")}
placeholder={$t("placeholder.password")} bind:value={localUser.role_id}
bind:value={password} options={userRoleOptions}
otherPasswordValue={password2} />
/> {/if}
<InputField <InputField
name="password2" name="user[password]"
type="password" type="password"
label={$t("password_repeat")} label={$t("password")}
placeholder={$t("placeholder.password")} placeholder={$t("placeholder.password")}
bind:value={password2} bind:value={password}
otherPasswordValue={password} otherPasswordValue={password2}
/> />
<InputField <InputField
name="first_name" name="password2"
label={$t("first_name")} type="password"
bind:value={user.first_name} label={$t("password_repeat")}
placeholder={$t("placeholder.first_name")} placeholder={$t("placeholder.password")}
required={true} bind:value={password2}
/> otherPasswordValue={password}
<InputField />
name="last_name" <InputField
label={$t("last_name")} name="user[first_name]"
bind:value={user.last_name} label={$t("first_name")}
placeholder={$t("placeholder.last_name")} bind:value={localUser.first_name}
required={true} placeholder={$t("placeholder.first_name")}
/> required={true}
<InputField />
name="company" <InputField
label={$t("company")} name="user[last_name]"
bind:value={user.company} label={$t("last_name")}
placeholder={$t("placeholder.company")} bind:value={localUser.last_name}
/> placeholder={$t("placeholder.last_name")}
<InputField required={true}
name="email" />
type="email" <InputField
label={$t("email")} name="user[company]"
bind:value={user.email} label={$t("company")}
placeholder={$t("placeholder.email")} bind:value={localUser.company}
required={true} placeholder={$t("placeholder.company")}
/> />
<InputField <InputField
name="phone" name="user[email]"
type="tel" type="email"
label={$t("phone")} label={$t("email")}
bind:value={user.phone} bind:value={localUser.email}
placeholder={$t("placeholder.phone")} placeholder={$t("placeholder.email")}
/> required={true}
<InputField />
name="birth_date" <InputField
type="date" name="user[phone]"
label={$t("birth_date")} type="tel"
bind:value={user.date_of_birth} label={$t("phone")}
placeholder={$t("placeholder.birth_date")} bind:value={localUser.phone}
/> placeholder={$t("placeholder.phone")}
<InputField />
name="address" <InputField
label={$t("address")} name="user[date_of_birth]"
bind:value={user.address} type="date"
placeholder={$t("placeholder.address")} label={$t("date_of_birth")}
/> bind:value={localUser.date_of_birth}
<InputField placeholder={$t("placeholder.date_of_birth")}
name="zip_code" />
label={$t("zip_code")} <InputField
bind:value={user.zip_code} name="user[address]"
placeholder={$t("placeholder.zip_code")} label={$t("address")}
/> bind:value={localUser.address}
<InputField placeholder={$t("placeholder.address")}
name="city" />
label={$t("city")} <InputField
bind:value={user.city} name="user[zip_code]"
placeholder={$t("placeholder.city")} label={$t("zip_code")}
/> bind:value={localUser.zip_code}
<InputField placeholder={$t("placeholder.zip_code")}
name="notes" />
type="textarea" <InputField
label={$t("notes")} name="user[city]"
bind:value={user.notes} label={$t("city")}
placeholder={$t("placeholder.notes", { bind:value={localUser.city}
values: { name: user.first_name || "" }, placeholder={$t("placeholder.city")}
})} />
rows={10} <InputField
/> name="user[notes]"
</div> type="textarea"
<div label={$t("notes")}
class="tab-content" bind:value={localUser.notes}
style="display: {activeTab === 'licence' ? 'block' : 'none'}" placeholder={$t("placeholder.notes", {
> values: { name: localUser.first_name || "" },
<InputField })}
name="licence_status" rows={10}
type="select" />
label={$t("status")} </div>
bind:value={user.licence.status} <div
options={licenceStatusOptions} class="tab-content"
/> style="display: {activeTab === 'licence' ? 'block' : 'none'}"
<InputField >
name="licence_number" <InputField
type="text" name="user[licence][status]"
label={$t("licence_number")} type="select"
bind:value={user.licence.licence_number} label={$t("status")}
placeholder={$t("placeholder.licence_number")} bind:value={localUser.licence.status}
toUpperCase={true} options={licenceStatusOptions}
/> />
<InputField <InputField
name="issued_date" name="user[licence][number]"
type="date" type="text"
label={$t("issued_date")} label={$t("licence_number")}
bind:value={user.licence.issued_date} bind:value={localUser.licence.licence_number}
placeholder={$t("placeholder.issued_date")} placeholder={$t("placeholder.licence_number")}
/> toUpperCase={true}
<InputField />
name="expiration_date" <InputField
type="date" name="user[licence][issued_date]"
label={$t("expiration_date")} type="date"
bind:value={user.licence.expiration_date} label={$t("issued_date")}
placeholder={$t("placeholder.expiration_date")} bind:value={localUser.licence.issued_date}
/> placeholder={$t("placeholder.issued_date")}
<InputField />
name="country" <InputField
label={$t("country")} name="user[licence][expiration_date]"
bind:value={user.licence.country} type="date"
placeholder={$t("placeholder.issuing_country")} label={$t("expiration_date")}
/> bind:value={localUser.licence.expiration_date}
<div class="licence-categories"> placeholder={$t("placeholder.expiration_date")}
<h3>{$t("licence_categories")}</h3> />
<div class="checkbox-grid"> <InputField
{#each Object.entries(groupedCategories) as [group, categories], groupIndex} name="user[licence][country]"
{#if groupIndex > 0} label={$t("country")}
<div class="category-break" /> bind:value={localUser.licence.country}
{/if} placeholder={$t("placeholder.issuing_country")}
{#each categories as category} />
<div class="checkbox-item"> <div class="licence-categories">
<div class="checkbox-label-container"> <h3>{$t("licence_categories")}</h3>
<InputField <div class="checkbox-grid">
type="checkbox" {#each Object.entries(groupedCategories) as [group, categories], groupIndex}
name="licence_categories[]" {#if groupIndex > 0}
value={JSON.stringify(category)} <div class="category-break" />
label={category.category} {/if}
checked={user.licence.licence_categories != null && {#each categories as category}
user.licence.licence_categories.some( <div class="checkbox-item">
(cat) => cat.category === category.category <div class="checkbox-label-container">
)} <InputField
/> type="checkbox"
name="user[licence][categories[]]"
value={JSON.stringify(category)}
label={category.category}
checked={localUser.licence.licence_categories != null &&
localUser.licence.licence_categories.some(
(cat) => cat.category === category.category
)}
/>
</div>
<span class="checkbox-description">
{$t(`licenceCategory.${category.category}`)}
</span>
</div> </div>
<span class="checkbox-description"> {/each}
{$t(`licenceCategory.${category.category}`)}
</span>
</div>
{/each} {/each}
{/each} </div>
</div> </div>
</div> </div>
</div> <div
<div class="tab-content"
class="tab-content" style="display: {activeTab === 'membership' ? 'block' : 'none'}"
style="display: {activeTab === 'membership' ? 'block' : 'none'}" >
> <InputField
<InputField name="user[membership][status]"
name="membership_status" type="select"
type="select" label={$t("status")}
label={$t("status")} bind:value={localUser.membership.status}
bind:value={user.membership.status} options={membershipStatusOptions}
options={membershipStatusOptions} />
/> <InputField
<InputField name="user[membership][subscription_model][name]"
name="subscription_model_name" type="select"
type="select" label={$t("subscription_model")}
label={$t("subscription_model")} bind:value={localUser.membership.subscription_model.name}
bind:value={user.membership.subscription_model.name} options={subscriptionModelOptions}
options={subscriptionModelOptions} />
/> <div class="subscription-info">
<div class="subscription-info"> <div class="subscription-column">
<div class="subscription-column">
<p>
<strong>{$t("monthly_fee")}:</strong>
{selectedSubscriptionModel?.monthly_fee || "-"}
</p>
<p>
<strong>{$t("hourly_rate")}:</strong>
{selectedSubscriptionModel?.hourly_rate || "-"}
</p>
{#if selectedSubscriptionModel?.included_hours_per_year}
<p> <p>
<strong>{$t("included_hours_per_year")}:</strong> <strong>{$t("monthly_fee")}:</strong>
{selectedSubscriptionModel?.included_hours_per_year} {selectedSubscriptionModel?.monthly_fee || "-"}
</p> </p>
{/if}
{#if selectedSubscriptionModel?.included_hours_per_month}
<p> <p>
<strong>{$t("included_hours_per_month")}:</strong> <strong>{$t("hourly_rate")}:</strong>
{selectedSubscriptionModel?.included_hours_per_month} {selectedSubscriptionModel?.hourly_rate || "-"}
</p> </p>
{/if} {#if selectedSubscriptionModel?.included_hours_per_year}
</div> <p>
<div class="subscription-column"> <strong>{$t("included_hours_per_year")}:</strong>
<p> {selectedSubscriptionModel?.included_hours_per_year}
<strong>{$t("details")}:</strong> </p>
{selectedSubscriptionModel?.details || "-"} {/if}
</p> {#if selectedSubscriptionModel?.included_hours_per_month}
{#if selectedSubscriptionModel?.conditions} <p>
<p> <strong>{$t("included_hours_per_month")}:</strong>
<strong>{$t("conditions")}:</strong> {selectedSubscriptionModel?.included_hours_per_month}
{selectedSubscriptionModel?.conditions} </p>
</p> {/if}
{/if} </div>
<div class="subscription-column">
<p>
<strong>{$t("details")}:</strong>
{selectedSubscriptionModel?.details || "-"}
</p>
{#if selectedSubscriptionModel?.conditions}
<p>
<strong>{$t("conditions")}:</strong>
{selectedSubscriptionModel?.conditions}
</p>
{/if}
</div>
</div> </div>
<InputField
name="user[membership][start_date]"
type="date"
label={$t("start")}
bind:value={localUser.membership.start_date}
placeholder={$t("placeholder.start_date")}
/>
<InputField
name="user[membership][end_date]"
type="date"
label={$t("end")}
bind:value={localUser.membership.end_date}
placeholder={$t("placeholder.end_date")}
/>
<InputField
name="user[membership][parent_member_id]"
type="number"
label={$t("parent_member_id")}
bind:value={localUser.membership.parent_member_id}
placeholder={$t("placeholder.parent_member_id")}
/>
</div> </div>
<InputField <div
name="membership_start_date" class="tab-content"
type="date" style="display: {activeTab === 'bankaccount' ? 'block' : 'none'}"
label={$t("start")} >
bind:value={user.membership.start_date} <InputField
placeholder={$t("placeholder.start_date")} name="user[bank_account][account_holder_name]"
/> label={$t("bank_account_holder")}
<InputField bind:value={localUser.bank_account.account_holder_name}
name="membership_end_date" placeholder={$t("placeholder.bank_account_holder")}
type="date" />
label={$t("end")} <InputField
bind:value={user.membership.end_date} name="user[bank_account][bank_name]"
placeholder={$t("placeholder.end_date")} label={$t("bank_name")}
/> bind:value={localUser.bank_account.bank}
<InputField placeholder={$t("placeholder.bank_name")}
name="parent_member_id" />
type="number" <InputField
label={$t("parent_member_id")} name="user[bank_account][iban]"
bind:value={user.membership.parent_member_id} label={$t("iban")}
placeholder={$t("placeholder.parent_member_id")} bind:value={localUser.bank_account.iban}
/> placeholder={$t("placeholder.iban")}
</div> toUpperCase={true}
<div />
class="tab-content" <InputField
style="display: {activeTab === 'bankaccount' ? 'block' : 'none'}" name="user[bank_account][bic]"
> label={$t("bic")}
<InputField bind:value={localUser.bank_account.bic}
name="account_holder_name" placeholder={$t("placeholder.bic")}
label={$t("bank_account_holder")} toUpperCase={true}
bind:value={user.bank_account.account_holder_name} />
placeholder={$t("placeholder.bank_account_holder")} <InputField
/> name="user[bank_account][mandate_reference]"
<InputField label={$t("mandate_reference")}
name="bank" bind:value={localUser.bank_account.mandate_reference}
label={$t("bank_name")} placeholder={$t("placeholder.mandate_reference")}
bind:value={user.bank_account.bank} />
placeholder={$t("placeholder.bank_name")} <InputField
/> name="user[bank_account][mandate_date_signed]"
<InputField label={$t("mandate_date_signed")}
name="iban" type="date"
label={$t("iban")} bind:value={localUser.bank_account.mandate_date_signed}
bind:value={user.bank_account.iban} readonly={true}
placeholder={$t("placeholder.iban")} />
toUpperCase={true} </div>
/> <div class="button-container">
<InputField {#if isUpdating}
name="bic" <SmallLoader width={30} message={"Aktualisiere..."} />
label={$t("bic")} {:else}
bind:value={user.bank_account.bic} <button
placeholder={$t("placeholder.bic")} type="button"
toUpperCase={true} class="button-dark"
/> on:click={() => dispatch("cancel")}
<InputField >
name="mandate_reference" {$t("cancel")}</button
label={$t("mandate_reference")} >
bind:value={user.bank_account.mandate_reference} <button type="submit" class="button-dark">{$t("confirm")}</button>
placeholder={$t("placeholder.mandate_reference")} {/if}
/> </div>
<InputField </form>
name="mandate_date_signed" {/if}
label={$t("mandate_date_signed")}
type="date"
bind:value={user.bank_account.mandate_date_signed}
readonly={true}
/>
</div>
<div class="button-container">
{#if isUpdating}
<SmallLoader width={30} message={"Aktualisiere..."} />
{:else}
<button type="button" class="button-dark" on:click={close}>
{$t("cancel")}</button
>
<button type="submit" class="button-dark">{$t("confirm")}</button>
{/if}
</div>
</form>
<style> <style>
.category-break { .category-break {

View File

@@ -0,0 +1,151 @@
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
@import "bootstrap/scss/mixins";
// Core variables
$theme-colors: (
"primary": #d43aff,
"secondary": #595b5c,
"success": #00b7ef,
"warning": rgb(225 29 72),
"danger": #eb5424,
"light": #9b9b9b,
"dark": #2f2f2f,
);
// Typography
$font-family-base: "Quicksand", sans-serif;
$font-family-monospace: "Roboto Mono", monospace;
$font-size-base: 1rem;
$line-height-base: 1.8;
$headings-font-weight: normal;
$headings-color: #fff;
// Body
$body-bg: black;
$body-color: #9b9b9b;
// Links
$link-color: #00b7ef;
$link-decoration: none;
$link-hover-decoration: underline;
// Buttons
$btn-padding-y: 1.125rem; // 18px
$btn-padding-x: 1.75rem; // 28px
$btn-font-weight: 500;
$btn-letter-spacing: 1px;
$btn-border-width: 1px;
$btn-transition: all 0.3s ease-in-out;
// Forms
$input-bg: #494848;
$input-color: white;
$input-border-color: #494848;
$input-border-radius: 6px;
$input-padding-y: 0.625rem; // 10px
$input-padding-x: 0.625rem; // 10px
$input-font-family: $font-family-monospace;
$input-font-size: 1rem;
$input-focus-border-color: lighten($input-border-color, 10%);
$input-focus-box-shadow: none;
// Cards
$card-bg: #2f2f2f;
$card-border-radius: 3px;
$card-border-width: 0;
$card-spacer-y: 1.25rem;
$card-spacer-x: 1.25rem;
// Modals
$modal-content-bg: #2f2f2f;
$modal-header-border-color: #595b5c;
$modal-footer-border-color: #595b5c;
// Navbar
$navbar-dark-color: #9b9b9b;
$navbar-dark-hover-color: #fff;
$navbar-padding-y: 1rem;
$navbar-nav-link-padding-x: 1rem;
// Utilities
$border-radius: 3px;
$border-radius-lg: 6px;
$box-shadow: none;
// Custom utility classes
.text-uppercase {
letter-spacing: 1px;
}
// Custom button styles
.btn {
text-transform: uppercase;
&-dark {
@include button-variant(transparent, #595b5c);
&:hover {
border-color: #fff;
background-color: transparent;
}
}
&-primary {
&:hover {
background-color: darken(#d43aff, 5%);
}
}
}
// Custom input styles
.form-control {
&:focus {
background-color: lighten($input-bg, 5%);
}
}
html {
padding: 0 30px;
}
body {
max-width: 1200px;
margin: 5em auto 0 auto;
}
h2 {
margin: 0 0 45px 0;
font-size: 36px;
}
h3 {
margin: 0 0 2rem 0;
font-size: 32px;
}
p {
margin: 0 0 45px;
}
ul {
margin: 0 0 32px;
}
li strong {
color: #fff;
}
pre,
code {
display: inline;
font-size: 16px;
}
// Override Bootstrap's link hover behavior
a:hover {
text-decoration: none;
border-bottom-color: #00b7ef;
}
// Import Bootstrap after variable overrides
@import "bootstrap/scss/bootstrap";

View File

@@ -387,6 +387,46 @@ li strong {
margin-left: 1rem; margin-left: 1rem;
} }
} }
.button-group {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.btn {
font-family: "Roboto Mono", monospace;
letter-spacing: 1px;
padding: 18px 28px;
border: 1px solid;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
transition: border-color 0.3s ease-in-out, background-color 0.3s ease-in-out;
border-radius: 5px;
}
.btn.primary {
background-color: #4361ee;
border-color: #4361ee;
color: white;
}
.btn.primary:hover {
background-color: #3651d4;
border-color: #3651d4;
}
.btn.danger {
background-color: #dc2626;
border-color: #dc2626;
color: white;
}
.btn.danger:hover {
background-color: #c51f1f;
border-color: #c51f1f;
}
.warning { .warning {
margin: 20px 0; margin: 20px 0;
padding: 1rem; padding: 1rem;

View File

@@ -52,12 +52,12 @@ export default {
no_auth_token: "Nicht authorisiert, fehlender oder ungültiger Auth-Token", no_auth_token: "Nicht authorisiert, fehlender oder ungültiger Auth-Token",
jwt_parsing_error: jwt_parsing_error:
"Nicht authorisiert, Auth-Token konnte nicht gelesen werden", "Nicht authorisiert, Auth-Token konnte nicht gelesen werden",
unauthorized_update: "Sie sind nicht befugt dieses Update durchzuführen", unauthorized: "Sie sind nicht befugt diese Handlung durchzuführen",
internal_server_error: internal_server_error:
"Verdammt, fehler auf unserer Seite, probieren Sie es nochmal, danach rufen Sie nach Hilfe", "Verdammt, Fehler auf unserer Seite, probieren Sie es nochmal, danach rufen Sie jemanden vom Verein an.",
}, },
validation: { validation: {
no_user_id_provided: "Nutzer ID fehlt im Header", invalid_user_id: "Nutzer ID ungültig",
invalid_subscription_model: "Model nicht gefunden", invalid_subscription_model: "Model nicht gefunden",
user_not_found: "{field} konnte nicht gefunden werden", user_not_found: "{field} konnte nicht gefunden werden",
invalid_user_data: "Nutzerdaten ungültig", invalid_user_data: "Nutzerdaten ungültig",
@@ -98,6 +98,7 @@ export default {
L: "Land-, Forstwirtschaftsfahrzeuge, Stapler max 40km/h", L: "Land-, Forstwirtschaftsfahrzeuge, Stapler max 40km/h",
T: "Land-, Forstwirtschaftsfahrzeuge, Stapler max 60km/h", T: "Land-, Forstwirtschaftsfahrzeuge, Stapler max 60km/h",
}, },
users: "Mitglieder",
user: { user: {
login: "Nutzer Anmeldung", login: "Nutzer Anmeldung",
edit: "Nutzer bearbeiten", edit: "Nutzer bearbeiten",
@@ -144,7 +145,7 @@ export default {
last_name: "Nachname", last_name: "Nachname",
name: "Name", name: "Name",
phone: "Telefonnummer", phone: "Telefonnummer",
birth_date: "Geburtstag", date_of_birth: "Geburtstag",
status: "Status", status: "Status",
start: "Beginn", start: "Beginn",
end: "Ende", end: "Ende",
@@ -154,4 +155,25 @@ export default {
iban: "IBAN", iban: "IBAN",
bic: "BIC", bic: "BIC",
mandate_reference: "SEPA Mandat", mandate_reference: "SEPA Mandat",
subscriptions: "Tarifmodelle",
payments: "Zahlungen",
add_new: "Neu",
included_hours_per_year: "Inkludierte Stunden pro Jahr",
included_hours_per_month: "Inkludierte Stunden pro Monat",
// For payments section
payment: {
id: "Zahlungs-Nr",
amount: "Betrag",
date: "Datum",
status: "Status",
},
// For subscription statuses
subscriptionStatus: {
pending: "Ausstehend",
completed: "Abgeschlossen",
failed: "Fehlgeschlagen",
cancelled: "Storniert",
},
}; };

View File

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

View File

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

View File

@@ -7,6 +7,130 @@ import { formatError, userDatesFromRFC3339 } from "$lib/utils/helpers";
import { fail, redirect } from "@sveltejs/kit"; import { fail, redirect } from "@sveltejs/kit";
import { toRFC3339 } from "$lib/utils/helpers"; 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} */ /** @type {import('./$types').PageServerLoad} */
export async function load({ locals, params }) { export async function load({ locals, params }) {
// redirect user if not logged in // redirect user if not logged in
@@ -28,77 +152,24 @@ export const actions = {
updateUser: async ({ request, fetch, cookies, locals }) => { updateUser: async ({ request, fetch, cookies, locals }) => {
let formData = await request.formData(); let formData = await request.formData();
const licenceCategories = formData // Convert form data to nested object
.getAll("licence_categories[]") const rawData = formDataToObject(formData);
.filter((value) => typeof value === "string")
.map((value) => { const processedData = processFormData(rawData);
try {
return JSON.parse(value);
} catch (e) {
console.error("Failed to parse licence category:", value);
return null;
}
})
.filter(Boolean);
/** @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 // Remove undefined or null properties
const cleanUpdateData = JSON.parse( const cleanUpdateData = JSON.parse(
JSON.stringify(updateData), JSON.stringify(processedData),
(key, value) => (value !== null && value !== "" ? value : undefined) (key, value) => (value !== null && value !== "" ? value : undefined)
); );
console.dir(formData); console.dir(processedData.user.membership);
console.dir(cleanUpdateData); const isCreating = !processedData.user.id || processedData.user.id === 0;
const apiURL = `${BASE_API_URI}/backend/users/update/`; console.log("Is creating: ", isCreating);
const apiURL = `${BASE_API_URI}/backend/users/update`;
/** @type {RequestInit} */ /** @type {RequestInit} */
const requestUpdateOptions = { const requestOptions = {
method: "PATCH", method: isCreating ? "POST" : "PATCH",
credentials: "include", credentials: "include",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -106,7 +177,8 @@ export const actions = {
}, },
body: JSON.stringify(cleanUpdateData), body: JSON.stringify(cleanUpdateData),
}; };
const res = await fetch(apiURL, requestUpdateOptions);
const res = await fetch(apiURL, requestOptions);
if (!res.ok) { if (!res.ok) {
const response = await res.json(); const response = await res.json();
@@ -115,6 +187,7 @@ export const actions = {
} }
const response = await res.json(); const response = await res.json();
console.log("Server success response:", response);
locals.user = response; locals.user = response;
userDatesFromRFC3339(locals.user); userDatesFromRFC3339(locals.user);
throw redirect(303, `/auth/about/${response.id}`); 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> <script>
import { onMount } from "svelte";
import Modal from "$lib/components/Modal.svelte"; import Modal from "$lib/components/Modal.svelte";
import UserEditForm from "$lib/components/UserEditForm.svelte"; import UserEditForm from "$lib/components/UserEditForm.svelte";
import { Styles } from "@sveltestrap/sveltestrap";
import { t } from "svelte-i18n"; import { t } from "svelte-i18n";
import { page } from "$app/stores"; import { page } from "$app/stores";
/** @type {import('./$types').ActionData} */ /** @type {import('./$types').ActionData} */
export let form; 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 selectedUser = null;
let showModal = false; let showModal = false;
/** /**
* Opens the edit modal for the selected user. * 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) => { const openEditModal = (user) => {
selectedUser = user; selectedUser = user;
console.dir(selectedUser);
showModal = true; showModal = true;
}; };
/**
* Opens the delete modal for the selected user.
* @param {App.Locals['user']} user The user to edit.
*/
const openDelete = (user) => {};
const close = () => { const close = () => {
showModal = false; showModal = false;
selectedUser = null; selectedUser = null;
@@ -41,52 +38,329 @@
form.errors = undefined; form.errors = undefined;
} }
}; };
/**
* sets the active admin section for display
* @param {string} section The new active section.
*/
const setActiveSection = (section) => {
activeSection = section;
};
</script> </script>
<div class="admin-users-page"> <div class="container">
<h1>{$t("user.management")}</h1> <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" /> <!-- Main Content -->
<main class="main-content">
<table class="user-table"> {#if activeSection === "users"}
<thead> <div class="section-header">
<tr> <h2>{$t("users")}</h2>
<th>{$t("user.id")}</th> <button class="btn primary" on:click={() => openEditModal(null)}>
<th>{$t("name")}</th> <i class="fas fa-plus" />
<th>{$t("email")}</th> {$t("add_new")}
<th>{$t("status")}</th> </button>
<th>{$t("actions")}</th> </div>
</tr> <div class="accordion">
</thead> {#each users as user}
<tbody> <details class="accordion-item">
{#each users as user} <summary class="accordion-header">
<tr> {user.first_name}
<td>{user.id}</td> {user.last_name} - {user.email}
<td>{user.first_name} {user.last_name}</td> </summary>
<td>{user.email}</td> <div class="accordion-content">
<td>{$t("userStatus." + user.status)}</td> <table class="table">
<td> <tbody>
<button on:click={() => openEditModal(user)}>{$t("edit")}</button> <tr>
<button on:click={() => openDelete(user)}>{$t("delete")}</button> <th>{$t("user.id")}</th>
</td> <td>{user.id}</td>
</tr>{/each} </tr>
</tbody> <tr>
</table> <th>{$t("name")}</th>
<td>{user.first_name} {user.last_name}</td>
<div class="pagination" /> </tr>
<tr>
{#if showModal} <th>{$t("email")}</th>
<Modal on:close={close}> <td>{user.email}</td>
<UserEditForm </tr>
{form} <tr>
user={selectedUser} <th>{$t("status")}</th>
{subscriptions} <td>{$t("userStatus." + user.status)}</td>
{licence_categories} </tr>
on:cancel={close} </tbody>
/> </table>
</Modal> <div class="button-group">
{/if} <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> </div>
{#if showModal}
<Modal on:close={close}>
<UserEditForm
{form}
user={selectedUser}
{subscriptions}
{licence_categories}
on:cancel={close}
/>
</Modal>
{/if}
<style> <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> </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} */ /** @type {import('./$types').PageServerLoad} */
export async function load({ locals }) { export async function load({ locals }) {
// redirect user if logged in // redirect user if logged in
console.log("loading login page");
if (locals.user) { if (locals.user) {
console.log("user is logged in");
throw redirect(302, "/"); throw redirect(302, "/");
} }
} }
@@ -20,11 +22,11 @@ export const actions = {
* @returns Error data or redirects user to the home page or the previous page * @returns Error data or redirects user to the home page or the previous page
*/ */
login: async ({ request, fetch, cookies }) => { login: async ({ request, fetch, cookies }) => {
console.log("login action called");
const data = await request.formData(); const data = await request.formData();
const email = String(data.get("email")); const email = String(data.get("email"));
const password = String(data.get("password")); const password = String(data.get("password"));
const next = String(data.get("next")); const next = String(data.get("next"));
/** @type {RequestInit} */ /** @type {RequestInit} */
const requestInitOptions = { const requestInitOptions = {
method: "POST", method: "POST",
@@ -37,9 +39,7 @@ export const actions = {
password: password, 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 status:", res.status);
console.log("Login response headers:", Object.fromEntries(res.headers)); console.log("Login response headers:", Object.fromEntries(res.headers));

View File

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

View File

@@ -1,16 +1,17 @@
import { sveltePreprocess } from "svelte-preprocess";
// import adapter from '@sveltejs/adapter-auto'; // import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-vercel'; import adapter from "@sveltejs/adapter-vercel";
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
kit: { kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter. // If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters. // See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter({ adapter: adapter({
runtime: 'edge' runtime: "edge",
}) }),
} },
}; };
export default config; export default config;

View File

@@ -51,6 +51,13 @@ func (uc *UserController) GetAllUsers(c *gin.Context) {
return return
} }
// Create a slice to hold the safe user representations
safeUsers := make([]map[string]interface{}, len(*users))
// Convert each user to its safe representation
for i, user := range *users {
safeUsers[i] = user.Safe()
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"users": users, "users": users,
}) })
@@ -65,10 +72,12 @@ func (uc *UserController) UpdateHandler(c *gin.Context) {
} }
var user models.User var user models.User
if err := c.ShouldBindJSON(&user); err != nil { var updateData RegistrationData
if err := c.ShouldBindJSON(&updateData); err != nil {
utils.HandleValidationError(c, err) utils.HandleValidationError(c, err)
return return
} }
user = updateData.User
if !utils.HasPrivilige(requestUser, constants.Priviliges.Update) && user.ID != requestUser.ID { if !utils.HasPrivilige(requestUser, constants.Priviliges.Update) && user.ID != requestUser.ID {
utils.RespondWithError(c, errors.ErrNotAuthorized, "Not allowed to update user", http.StatusForbidden, "user", "server.error.unauthorized") utils.RespondWithError(c, errors.ErrNotAuthorized, "Not allowed to update user", http.StatusForbidden, "user", "server.error.unauthorized")

View File

@@ -9,13 +9,16 @@ import (
) )
type User struct { type User struct {
gorm.Model ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time `gorm:"index"`
DateOfBirth time.Time `gorm:"not null" json:"date_of_birth" binding:"required,safe_content"` DateOfBirth time.Time `gorm:"not null" json:"date_of_birth" binding:"required,safe_content"`
Company string `json:"company" binding:"omitempty,omitnil,safe_content"` Company string `json:"company" binding:"omitempty,omitnil,safe_content"`
Phone string `json:"phone" binding:"omitempty,omitnil,safe_content"` Phone string `json:"phone" binding:"omitempty,omitnil,safe_content"`
Notes string `json:"notes" binding:"safe_content"` Notes string `json:"notes" binding:"safe_content"`
FirstName string `gorm:"not null" json:"first_name" binding:"required,safe_content"` FirstName string `gorm:"not null" json:"first_name" binding:"required,safe_content"`
Password string `json:"password" binding:"required_unless=RoleID 0,safe_content"` Password string `json:"password" binding:"safe_content"`
Email string `gorm:"unique;not null" json:"email" binding:"required,email,safe_content"` Email string `gorm:"unique;not null" json:"email" binding:"required,email,safe_content"`
LastName string `gorm:"not null" json:"last_name" binding:"required,safe_content"` LastName string `gorm:"not null" json:"last_name" binding:"required,safe_content"`
ProfilePicture string `json:"profile_picture" binding:"omitempty,omitnil,image,safe_content"` ProfilePicture string `json:"profile_picture" binding:"omitempty,omitnil,image,safe_content"`
@@ -31,7 +34,6 @@ type User struct {
MembershipID uint MembershipID uint
Licence *Licence `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"licence"` Licence *Licence `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"licence"`
LicenceID uint LicenceID uint
ID uint `json:"id"`
PaymentStatus int8 `json:"payment_status"` PaymentStatus int8 `json:"payment_status"`
Status int8 `json:"status"` Status int8 `json:"status"`
RoleID int8 `json:"role_id"` RoleID int8 `json:"role_id"`

View File

@@ -25,6 +25,15 @@ type UserRepositoryInterface interface {
type UserRepository struct{} type UserRepository struct{}
func PasswordExists(userID *uint) (bool, error) {
var user models.User
result := database.DB.Select("password").First(&user, userID)
if result.Error != nil {
return false, result.Error
}
return user.Password != "", nil
}
func (ur *UserRepository) CreateUser(user *models.User) (uint, error) { func (ur *UserRepository) CreateUser(user *models.User) (uint, error) {
result := database.DB.Create(user) result := database.DB.Create(user)
if result.Error != nil { if result.Error != nil {
@@ -57,30 +66,6 @@ func (ur *UserRepository) UpdateUser(user *models.User) (*models.User, error) {
return errors.ErrNoRowsAffected return errors.ErrNoRowsAffected
} }
// Handle the update or creation of Licence and its Categories
// if user.Licence != nil {
// if existingUser.Licence == nil {
// // Create new Licence if it doesn't exist
// logger.Error.Printf("Licence creation: %+v", user.Licence)
// if err := tx.Create(user.Licence).Error; err != nil {
// return err
// }
// // Update user with new licence ID
// // if err := tx.Model(&existingUser).Update("licence_id", user.Licence.ID).Error; err != nil {
// // return err
// // }
// } else {
// // Update existing licence
// if err := tx.Model(&existingUser.Licence).Updates(user.Licence).Error; err != nil {
// return err
// }
// }
// // Replace the Categories with the new list
// if err := tx.Model(&existingUser.Licence).Association("Categories").Replace(user.Licence.Categories); err != nil {
// return err
// }
// }
// Update the Membership if provided // Update the Membership if provided
if user.Membership.ID != 0 { if user.Membership.ID != 0 {
if err := tx.Model(&existingUser.Membership).Updates(user.Membership).Error; err != nil { if err := tx.Model(&existingUser.Membership).Updates(user.Membership).Error; err != nil {

View File

@@ -27,6 +27,7 @@ func RegisterRoutes(router *gin.Engine, userController *controllers.UserControll
userRouter.GET("/current", userController.CurrentUserHandler) userRouter.GET("/current", userController.CurrentUserHandler)
userRouter.POST("/logout", userController.LogoutHandler) userRouter.POST("/logout", userController.LogoutHandler)
userRouter.PATCH("/update", userController.UpdateHandler) userRouter.PATCH("/update", userController.UpdateHandler)
userRouter.POST("/update", userController.RegisterUser)
userRouter.GET("/all", userController.GetAllUsers) userRouter.GET("/all", userController.GetAllUsers)
} }

View File

@@ -14,11 +14,20 @@ func validateUser(sl validator.StructLevel) {
user := sl.Current().Interface().(models.User) user := sl.Current().Interface().(models.User)
isSuper := user.RoleID >= constants.Roles.Admin isSuper := user.RoleID >= constants.Roles.Admin
if user.RoleID > constants.Roles.Member && user.Password == "" {
passwordExists, err := repositories.PasswordExists(&user.ID)
if err != nil || !passwordExists {
logger.Error.Printf("Error checking password exists for user %v: %v", user.Email, err)
sl.ReportError(user.Password, "Password", "password", "required", "")
}
}
// Validate User > 18 years old // Validate User > 18 years old
if !isSuper && user.DateOfBirth.After(time.Now().AddDate(-18, 0, 0)) { if !isSuper && user.DateOfBirth.After(time.Now().AddDate(-18, 0, 0)) {
sl.ReportError(user.DateOfBirth, "DateOfBirth", "date_of_birth", "age", "") sl.ReportError(user.DateOfBirth, "DateOfBirth", "date_of_birth", "age", "")
} }
// validate subscriptionModel // validate subscriptionModel
logger.Error.Printf("User: %#v", user)
if user.Membership.SubscriptionModel.Name == "" { if user.Membership.SubscriptionModel.Name == "" {
sl.ReportError(user.Membership.SubscriptionModel.Name, "SubscriptionModel.Name", "name", "required", "") sl.ReportError(user.Membership.SubscriptionModel.Name, "SubscriptionModel.Name", "name", "required", "")
} else { } else {