Merge pull request #946 from ae-utbm/product-csv

Rework the product admin page
This commit is contained in:
thomas girod 2024-12-21 15:50:34 +01:00 committed by GitHub
commit 6d02970676
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 665 additions and 214 deletions

View File

@ -22,10 +22,13 @@ type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>(
// TODO : If one day a test workflow is made for JS in this project // TODO : If one day a test workflow is made for JS in this project
// please test this function. A all cost. // please test this function. A all cost.
/**
* Load complete dataset from paginated routes.
*/
export const paginated = async <T>( export const paginated = async <T>(
endpoint: PaginatedEndpoint<T>, endpoint: PaginatedEndpoint<T>,
options?: PaginatedRequest, options?: PaginatedRequest,
) => { ): Promise<T[]> => {
const maxPerPage = 199; const maxPerPage = 199;
const queryParams = options ?? {}; const queryParams = options ?? {};
queryParams.query = queryParams.query ?? {}; queryParams.query = queryParams.query ?? {};

View File

@ -0,0 +1,49 @@
import type { NestedKeyOf } from "#core:utils/types";
interface StringifyOptions<T extends object> {
/** The columns to include in the resulting CSV. */
columns: readonly NestedKeyOf<T>[];
/** Content of the first row */
titleRow?: readonly string[];
}
function getNested<T extends object>(obj: T, key: NestedKeyOf<T>) {
const path: (keyof object)[] = key.split(".") as (keyof unknown)[];
let res = obj[path.shift() as keyof T];
for (const node of path) {
if (res === null) {
break;
}
res = res[node];
}
return res;
}
/**
* Convert the content the string to make sure it won't break
* the resulting csv.
* cf. https://en.wikipedia.org/wiki/Comma-separated_values#Basic_rules
*/
function sanitizeCell(content: string): string {
return `"${content.replace(/"/g, '""')}"`;
}
export const csv = {
stringify: <T extends object>(objs: T[], options?: StringifyOptions<T>) => {
const columns = options.columns;
const content = objs
.map((obj) => {
return columns
.map((col) => {
return sanitizeCell((getNested(obj, col) ?? "").toString());
})
.join(",");
})
.join("\n");
if (!options.titleRow) {
return content;
}
const firstRow = options.titleRow.map(sanitizeCell).join(",");
return `${firstRow}\n${content}`;
},
};

37
core/static/bundled/utils/types.d.ts vendored Normal file
View File

@ -0,0 +1,37 @@
/**
* A key of an object, or of one of its descendants.
*
* Example :
* ```typescript
* interface Foo {
* foo_inner: number;
* }
*
* interface Bar {
* foo: Foo;
* }
*
* const foo = (key: NestedKeyOf<Bar>) {
* console.log(key);
* }
*
* foo("foo.foo_inner"); // OK
* foo("foo.bar"); // FAIL
* ```
*/
export type NestedKeyOf<T extends object> = {
[Key in keyof T & (string | number)]: NestedKeyOfHandleValue<T[Key], `${Key}`>;
}[keyof T & (string | number)];
type NestedKeyOfInner<T extends object> = {
[Key in keyof T & (string | number)]: NestedKeyOfHandleValue<
T[Key],
`['${Key}']` | `.${Key}`
>;
}[keyof T & (string | number)];
type NestedKeyOfHandleValue<T, Text extends string> = T extends unknown[]
? Text
: T extends object
? Text | `${Text}${NestedKeyOfInner<T>}`
: Text;

View File

@ -0,0 +1,96 @@
@import "core/static/core/colors";
@mixin row-layout {
min-height: 100px;
width: 100%;
max-width: 100%;
display: flex;
flex-direction: row;
gap: 10px;
.card-image {
max-width: 75px;
}
.card-content {
flex: 1;
text-align: left;
}
}
.card {
background-color: $primary-neutral-light-color;
border-radius: 5px;
position: relative;
box-sizing: border-box;
padding: 20px 10px;
height: fit-content;
width: 150px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
&:hover {
background-color: darken($primary-neutral-light-color, 5%);
}
&.selected {
animation: bg-in-out 1s ease;
background-color: rgb(216, 236, 255);
}
.card-image {
width: 100%;
height: 100%;
min-height: 70px;
max-height: 70px;
object-fit: contain;
border-radius: 4px;
line-height: 70px;
}
i.card-image {
color: black;
text-align: center;
background-color: rgba(173, 173, 173, 0.2);
width: 80%;
}
.card-content {
color: black;
display: flex;
flex-direction: column;
gap: 5px;
width: 100%;
p {
font-size: 13px;
margin: 0;
}
.card-title {
margin: 0;
font-size: 15px;
word-break: break-word;
}
}
@keyframes bg-in-out {
0% {
background-color: white;
}
100% {
background-color: rgb(216, 236, 255);
}
}
@media screen and (max-width: 765px) {
@include row-layout
}
// When combined with card, card-row display the card in a row layout,
// whatever the size of the screen.
&.card-row {
@include row-layout
}
}

View File

@ -107,7 +107,7 @@ form {
} }
} }
label { label, legend {
display: block; display: block;
margin-bottom: 8px; margin-bottom: 8px;

View File

@ -19,6 +19,13 @@ body {
--loading-stroke: 5px; --loading-stroke: 5px;
--loading-duration: 1s; --loading-duration: 1s;
position: relative; position: relative;
&.aria-busy-grow {
// Make sure the element take enough place to hold the loading wheel
min-height: calc((var(--loading-size)) * 1.5);
min-width: calc((var(--loading-size)) * 1.5);
overflow: hidden;
}
} }
[aria-busy]:after { [aria-busy]:after {
@ -198,6 +205,9 @@ body {
margin: 20px auto 0; margin: 20px auto 0;
/*---------------------------------NAV---------------------------------*/ /*---------------------------------NAV---------------------------------*/
a.btn {
display: inline-block;
}
.btn { .btn {
font-size: 15px; font-size: 15px;
font-weight: normal; font-weight: normal;
@ -252,6 +262,13 @@ body {
} }
} }
/**
* A spacer below an element. Somewhat cleaner than putting <br/> everywhere.
*/
.margin-bottom {
margin-bottom: 1.5rem;
}
/*--------------------------------CONTENT------------------------------*/ /*--------------------------------CONTENT------------------------------*/
#quick_notif { #quick_notif {
width: 100%; width: 100%;
@ -409,6 +426,21 @@ body {
} }
} }
.row {
display: flex;
flex-wrap: wrap;
$col-gap: 1rem;
&.gap {
column-gap: var($col-gap);
}
@for $i from 2 through 5 {
&.gap-#{$i}x {
column-gap: $i * $col-gap;
}
}
}
/*---------------------------------NEWS--------------------------------*/ /*---------------------------------NEWS--------------------------------*/
#news { #news {
display: flex; display: flex;

View File

@ -1,3 +1,5 @@
@import "core/static/core/colors";
main { main {
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
@ -69,7 +71,7 @@ main {
border-radius: 50%; border-radius: 50%;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background-color: #f2f2f2; background-color: $primary-neutral-light-color;
> span { > span {
font-size: small; font-size: small;

View File

@ -140,7 +140,7 @@
nb_page (str): call to a javascript function or variable returning nb_page (str): call to a javascript function or variable returning
the maximum number of pages to paginate the maximum number of pages to paginate
#} #}
<nav class="pagination" x-show="{{ nb_pages }} > 1"> <nav class="pagination" x-show="{{ nb_pages }} > 1" x-cloak>
{# Adding the prevent here is important, because otherwise, {# Adding the prevent here is important, because otherwise,
clicking on the pagination buttons could submit the picture management form clicking on the pagination buttons could submit the picture management form
and reload the page #} and reload the page #}

View File

@ -4,9 +4,11 @@ import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils"; import type { escape_html } from "tom-select/dist/types/utils";
import { import {
type CounterSchema, type CounterSchema,
type ProductTypeSchema,
type SimpleProductSchema, type SimpleProductSchema,
counterSearchCounter, counterSearchCounter,
productSearchProducts, productSearchProducts,
producttypeFetchAll,
} from "#openapi"; } from "#openapi";
@registerComponent("product-ajax-select") @registerComponent("product-ajax-select")
@ -34,6 +36,37 @@ export class ProductAjaxSelect extends AjaxSelect {
} }
} }
@registerComponent("product-type-ajax-select")
export class ProductTypeAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["name"];
private productTypes = null as ProductTypeSchema[];
protected async search(query: string): Promise<TomOption[]> {
// The production database has a grand total of 26 product types
// and the filter logic is really simple.
// Thus, it's appropriate to fetch all product types during first use,
// then to reuse the result again and again.
if (this.productTypes === null) {
this.productTypes = (await producttypeFetchAll()).data || null;
}
return this.productTypes.filter((t) =>
t.name.toLowerCase().includes(query.toLowerCase()),
);
}
protected renderOption(item: ProductTypeSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: ProductTypeSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}
@registerComponent("counter-ajax-select") @registerComponent("counter-ajax-select")
export class CounterAjaxSelect extends AjaxSelect { export class CounterAjaxSelect extends AjaxSelect {
protected valueField = "id"; protected valueField = "id";

View File

@ -0,0 +1,163 @@
import { paginated } from "#core:utils/api";
import { csv } from "#core:utils/csv";
import { History, getCurrentUrlParams, updateQueryString } from "#core:utils/history";
import type { NestedKeyOf } from "#core:utils/types";
import { showSaveFilePicker } from "native-file-system-adapter";
import type TomSelect from "tom-select";
import {
type ProductSchema,
type ProductSearchProductsDetailedData,
productSearchProductsDetailed,
} from "#openapi";
type ProductType = string;
type GroupedProducts = Record<ProductType, ProductSchema[]>;
const defaultPageSize = 100;
const defaultPage = 1;
/**
* Keys of the properties to include in the CSV.
*/
const csvColumns = [
"id",
"name",
"code",
"description",
"product_type.name",
"club.name",
"limit_age",
"purchase_price",
"selling_price",
"archived",
] as NestedKeyOf<ProductSchema>[];
/**
* Title of the csv columns.
*/
const csvColumnTitles = [
"id",
gettext("name"),
"code",
"description",
gettext("product type"),
"club",
gettext("limit age"),
gettext("purchase price"),
gettext("selling price"),
gettext("archived"),
];
document.addEventListener("alpine:init", () => {
Alpine.data("productList", () => ({
loading: false,
csvLoading: false,
products: {} as GroupedProducts,
/** Total number of elements corresponding to the current query. */
nbPages: 0,
productStatus: "" as "active" | "archived" | "both",
search: "",
productTypes: [] as string[],
pageSize: defaultPageSize,
page: defaultPage,
async init() {
const url = getCurrentUrlParams();
this.search = url.get("search") || "";
this.productStatus = url.get("productStatus") ?? "active";
const widget = this.$refs.productTypesInput.widget as TomSelect;
widget.on("change", (items: string[]) => {
this.productTypes = [...items];
});
await this.load();
const searchParams = ["search", "productStatus", "productTypes"];
for (const param of searchParams) {
this.$watch(param, () => {
this.page = defaultPage;
});
}
for (const param of [...searchParams, "page"]) {
this.$watch(param, async (value: string) => {
updateQueryString(param, value, History.Replace);
this.nbPages = 0;
await this.load();
});
}
},
/**
* Build the object containing the query parameters corresponding
* to the current filters
*/
getQueryParams(): ProductSearchProductsDetailedData {
const search = this.search.length > 0 ? this.search : null;
// If active or archived products must be filtered, put the filter in the request
// Else, don't include the filter
const isArchived = ["active", "archived"].includes(this.productStatus)
? this.productStatus === "archived"
: undefined;
return {
query: {
page: this.page,
// biome-ignore lint/style/useNamingConvention: api is in snake_case
page_size: this.pageSize,
search: search,
// biome-ignore lint/style/useNamingConvention: api is in snake_case
is_archived: isArchived,
// biome-ignore lint/style/useNamingConvention: api is in snake_case
product_type: this.productTypes,
},
};
},
/**
* Fetch the products corresponding to the current filters
*/
async load() {
this.loading = true;
const options = this.getQueryParams();
const resp = await productSearchProductsDetailed(options);
this.nbPages = Math.ceil(resp.data.count / defaultPageSize);
this.products = resp.data.results.reduce<GroupedProducts>((acc, curr) => {
const key = curr.product_type?.name ?? gettext("Uncategorized");
if (!(key in acc)) {
acc[key] = [];
}
acc[key].push(curr);
return acc;
}, {});
this.loading = false;
},
/**
* Download products corresponding to the current filters as a CSV file.
* If the pagination has multiple pages, all pages are downloaded.
*/
async downloadCsv() {
this.csvLoading = true;
const fileHandle = await showSaveFilePicker({
_preferPolyfill: false,
suggestedName: gettext("products.csv"),
types: [],
excludeAcceptAllOption: false,
});
// if products to download are already in-memory, directly take them.
// If not, fetch them.
const products =
this.nbPages > 1
? await paginated(productSearchProductsDetailed, this.getQueryParams())
: Object.values<ProductSchema[]>(this.products).flat();
const content = csv.stringify(products, {
columns: csvColumns,
titleRow: csvColumnTitles,
});
const file = await fileHandle.createWritable();
await file.write(content);
await file.close();
this.csvLoading = false;
},
}));
});

View File

@ -43,7 +43,7 @@ document.addEventListener("alpine:init", () => {
openAlertMessage(response: Response) { openAlertMessage(response: Response) {
if (response.ok) { if (response.ok) {
this.alertMessage.success = true; this.alertMessage.success = true;
this.alertMessage.content = gettext("Products types successfully reordered"); this.alertMessage.content = gettext("Products types reordered!");
} else { } else {
this.alertMessage.success = false; this.alertMessage.success = false;
this.alertMessage.content = interpolate( this.alertMessage.content = interpolate(

View File

@ -0,0 +1,11 @@
.product-group {
display: flex;
flex-direction: column;
gap: 15px;
margin-bottom: 30px;
@media screen and (min-width: 768px) {
max-width: 50%;
}
}

View File

@ -1,26 +1,103 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from "core/macros.jinja" import paginate_alpine %}
{% block title %} {% block title %}
{% trans %}Product list{% endtrans %} {% trans %}Product list{% endtrans %}
{% endblock %} {% endblock %}
{% block content %} {% block additional_js %}
{% if current_tab == "products" %} <script type="module" src="{{ static("bundled/counter/components/ajax-select-index.ts") }}"></script>
<p><a href="{{ url('counter:new_product') }}">{% trans %}New product{% endtrans %}</a></p> <script type="module" src="{{ static("bundled/counter/product-list-index.ts") }}"></script>
{% endif %}
<h3>{% trans %}Product list{% endtrans %}</h3>
{%- for product_type, products in object_list -%}
<h4>{{ product_type or _("Uncategorized") }}</h4>
<ul>
{%- for product in products -%}
<li><a href="{{ url('counter:product_edit', product_id=product.id) }}">{{ product.name }} ({{ product.code }})</a></li>
{%- endfor -%}
</ul>
{%- else -%}
{% trans %}There is no products in this website.{% endtrans %}
{%- endfor -%}
{% endblock %} {% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
<link rel="stylesheet" href="{{ static("counter/css/admin.scss") }}">
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
<link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
{% endblock %}
{% block content %}
<main x-data="productList">
<h4 class="margin-bottom">{% trans %}Filter products{% endtrans %}</h4>
<form id="search-form" class="margin-bottom">
<div class="row gap-4x">
<fieldset>
<label for="search-input">{% trans %}Product name{% endtrans %}</label>
<input
id="search-input"
type="text"
name="search"
x-model.debounce.500ms="search"
/>
</fieldset>
<fieldset>
<legend>{% trans %}Product state{% endtrans %}</legend>
<div class="row">
<input type="radio" id="filter-active-products" x-model="productStatus" value="active">
<label for="filter-active-products">{% trans %}Active products{% endtrans %}</label>
</div>
<div class="row">
<input type="radio" id="filter-inactive-products" x-model="productStatus" value="archived">
<label for="filter-inactive-products">{% trans %}Archived products{% endtrans %}</label>
</div>
<div class="row">
<input type="radio" id="filter-all-products" x-model="productStatus" value="both">
<label for="filter-all-products">{% trans %}All products{% endtrans %}</label>
</div>
</fieldset>
</div>
<fieldset>
<label for="type-search-input">{% trans %}Product type{% endtrans %}</label>
<product-type-ajax-select
id="type-search-input"
name="product-type"
x-ref="productTypesInput"
multiple
>
</product-type-ajax-select>
</fieldset>
</form>
<h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3>
<div class="row margin-bottom">
<a href="{{ url('counter:new_product') }}" class="btn btn-blue">
{% trans %}New product{% endtrans %} <i class="fa fa-plus"></i>
</a>
<button
class="btn btn-blue"
@click="downloadCsv()"
:disabled="csvLoading"
:aria-busy="csvLoading"
>
{% trans %}Download as cvs{% endtrans %} <i class="fa fa-file-arrow-down"></i>
</button>
</div>
<div class="aria-busy-grow" :aria-busy="loading">
<template x-for="[category, cat_products] of Object.entries(products)" :key="category">
<section>
<h4 x-text="category" class="margin-bottom"></h4>
<div class="product-group">
<template x-for="p in cat_products" :key="p.id">
<a class="card card-row shadow clickable" :href="p.url">
<template x-if="p.icon">
<img class="card-image" :src="p.icon" :alt="`icon ${p.name}`">
</template>
<template x-if="!p.icon">
<i class="fa-regular fa-image fa-2x card-image"></i>
</template>
<span class="card-content">
<strong class="card-title" x-text="`${p.name} (${p.code})`"></strong>
<p x-text="`${p.selling_price} €`"></p>
</span>
</a>
</template>
</div>
</section>
</template>
{{ paginate_alpine("page", "nbPages") }}
</div>
</main>
{% endblock %}

View File

@ -16,8 +16,6 @@
from django.urls import path from django.urls import path
from counter.views.admin import ( from counter.views.admin import (
ActiveProductListView,
ArchivedProductListView,
CounterCreateView, CounterCreateView,
CounterDeleteView, CounterDeleteView,
CounterEditPropView, CounterEditPropView,
@ -27,6 +25,7 @@ from counter.views.admin import (
CounterStatView, CounterStatView,
ProductCreateView, ProductCreateView,
ProductEditView, ProductEditView,
ProductListView,
ProductTypeCreateView, ProductTypeCreateView,
ProductTypeEditView, ProductTypeEditView,
ProductTypeListView, ProductTypeListView,
@ -108,12 +107,7 @@ urlpatterns = [
CashSummaryEditView.as_view(), CashSummaryEditView.as_view(),
name="cash_summary_edit", name="cash_summary_edit",
), ),
path("admin/product/list/", ActiveProductListView.as_view(), name="product_list"), path("admin/product/", ProductListView.as_view(), name="product_list"),
path(
"admin/product/list_archived/",
ArchivedProductListView.as_view(),
name="product_list_archived",
),
path("admin/product/create/", ProductCreateView.as_view(), name="new_product"), path("admin/product/create/", ProductCreateView.as_view(), name="new_product"),
path( path(
"admin/product/<int:product_id>/", "admin/product/<int:product_id>/",

View File

@ -12,19 +12,16 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
import itertools
from datetime import timedelta from datetime import timedelta
from operator import itemgetter
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import F
from django.forms import CheckboxSelectMultiple from django.forms import CheckboxSelectMultiple
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView, TemplateView
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.utils import get_semester_code, get_start_of_semester from core.utils import get_semester_code, get_start_of_semester
@ -125,40 +122,9 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
current_tab = "products" current_tab = "products"
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView): class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
model = Product
queryset = Product.objects.values("id", "name", "code", "product_type__name")
template_name = "counter/product_list.jinja"
ordering = [
F("product_type__order").asc(nulls_last=True),
"product_type",
"name",
]
def get_context_data(self, **kwargs):
res = super().get_context_data(**kwargs)
res["object_list"] = itertools.groupby(
res["object_list"], key=itemgetter("product_type__name")
)
return res
class ArchivedProductListView(ProductListView):
"""A list view for the admins."""
current_tab = "archive"
def get_queryset(self):
return super().get_queryset().filter(archived=True)
class ActiveProductListView(ProductListView):
"""A list view for the admins."""
current_tab = "products" current_tab = "products"
template_name = "counter/product_list.jinja"
def get_queryset(self):
return super().get_queryset().filter(archived=False)
class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView): class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):

View File

@ -93,11 +93,6 @@ class CounterAdminTabsMixin(TabedViewMixin):
"slug": "products", "slug": "products",
"name": _("Products"), "name": _("Products"),
}, },
{
"url": reverse_lazy("counter:product_list_archived"),
"slug": "archive",
"name": _("Archived products"),
},
{ {
"url": reverse_lazy("counter:product_type_list"), "url": reverse_lazy("counter:product_type_list"),
"slug": "product_types", "slug": "product_types",

View File

@ -1,8 +1,12 @@
from pydantic import TypeAdapter from pydantic import TypeAdapter
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
from counter.models import Counter, Product from counter.models import Counter, Product, ProductType
from counter.schemas import SimpleProductSchema, SimplifiedCounterSchema from counter.schemas import (
ProductTypeSchema,
SimpleProductSchema,
SimplifiedCounterSchema,
)
_js = ["bundled/counter/components/ajax-select-index.ts"] _js = ["bundled/counter/components/ajax-select-index.ts"]
@ -33,3 +37,17 @@ class AutoCompleteSelectMultipleProduct(AutoCompleteSelectMultiple):
model = Product model = Product
adapter = TypeAdapter(list[SimpleProductSchema]) adapter = TypeAdapter(list[SimpleProductSchema])
js = _js js = _js
class AutoCompleteSelectProductType(AutoCompleteSelect):
component_name = "product-type-ajax-select"
model = ProductType
adapter = TypeAdapter(list[ProductTypeSchema])
js = _js
class AutoCompleteSelectMultipleProductType(AutoCompleteSelectMultiple):
component_name = "product-type-ajax-select"
model = ProductType
adapter = TypeAdapter(list[ProductTypeSchema])
js = _js

View File

@ -108,28 +108,8 @@
column-gap: 15px; column-gap: 15px;
row-gap: 15px; row-gap: 15px;
} }
#eboutic .product-button {
position: relative;
box-sizing: border-box;
min-height: 180px;
height: fit-content;
width: 150px;
padding: 15px;
overflow: hidden;
box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 4px 8px 3px;
display: flex;
flex-direction: column;
align-items: center;
row-gap: 5px;
justify-content: flex-start;
}
#eboutic .product-button.selected { #eboutic .card.selected::after {
animation: bg-in-out 1s ease;
background-color: rgb(216, 236, 255);
}
#eboutic .product-button.selected::after {
content: "🛒"; content: "🛒";
position: absolute; position: absolute;
top: 5px; top: 5px;
@ -144,36 +124,6 @@
line-height: 20px; line-height: 20px;
} }
#eboutic .product-button:active {
box-shadow: none;
}
#eboutic .product-image {
width: 100%;
height: 100%;
min-height: 70px;
max-height: 70px;
object-fit: contain;
border-radius: 4px;
line-height: 70px;
margin-bottom: 15px;
}
#eboutic i.product-image {
background-color: rgba(173, 173, 173, 0.2);
}
#eboutic .product-description h4 {
font-size: .75em;
word-break: break-word;
margin: 0 0 5px 0;
}
#eboutic .product-button p {
font-size: 13px;
margin: 0;
}
#eboutic .catalog-buttons { #eboutic .catalog-buttons {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -207,39 +157,5 @@
justify-content: space-around; justify-content: space-around;
flex-direction: column; flex-direction: column;
} }
#eboutic .product-group .product-button {
min-height: 100px;
width: 100%;
max-width: 100%;
display: flex;
flex-direction: row;
gap: 10px;
} }
#eboutic .product-group .product-description {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
#eboutic .product-description h4 {
text-align: left;
max-width: 90%;
}
#eboutic .product-image {
margin-bottom: 0;
max-width: 70px;
}
}
@keyframes bg-in-out {
0% {
background-color: white;
}
100% {
background-color: rgb(216, 236, 255);
}
}

View File

@ -15,7 +15,8 @@
{% endblock %} {% endblock %}
{% block additional_css %} {% block additional_css %}
<link rel="stylesheet" href="{{ static('eboutic/css/eboutic.css') }}"> <link rel="stylesheet" href="{{ static("eboutic/css/eboutic.css") }}">
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@ -104,18 +105,21 @@
{% for p in items %} {% for p in items %}
<button <button
id="{{ p.id }}" id="{{ p.id }}"
class="product-button" class="card product-button clickable shadow"
:class="{selected: items.some((i) => i.id === {{ p.id }})}" :class="{selected: items.some((i) => i.id === {{ p.id }})}"
@click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})' @click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
> >
{% if p.icon %} {% if p.icon %}
<img class="product-image" src="{{ p.icon.url }}" <img
alt="image de {{ p.name }}"> class="card-image"
src="{{ p.icon.url }}"
alt="image de {{ p.name }}"
>
{% else %} {% else %}
<i class="fa-regular fa-image fa-2x product-image"></i> <i class="fa-regular fa-image fa-2x card-image"></i>
{% endif %} {% endif %}
<div class="product-description"> <div class="card-content">
<h4>{{ p.name }}</h4> <h4 class="card-title">{{ p.name }}</h4>
<p>{{ p.selling_price }} €</p> <p>{{ p.selling_price }} €</p>
</div> </div>
</button> </button>

View File

@ -1,3 +1,5 @@
@import "core/static/core/colors";
$padding: 1.5rem; $padding: 1.5rem;
$padding_smaller: .5rem; $padding_smaller: .5rem;
$gap: .25rem; $gap: .25rem;
@ -51,7 +53,6 @@ $min_col_width: 100px;
min-width: $min_col_width; min-width: $min_col_width;
>a { >a {
margin-left: $padding;
width: 20px; width: 20px;
height: 20px; height: 20px;
text-align: center; text-align: center;
@ -269,12 +270,12 @@ $min_col_width: 100px;
border: none; border: none;
color: black; color: black;
text-decoration: none; text-decoration: none;
background-color: #f2f2f2; background-color: $primary-neutral-light-color;
padding: 0.4em; padding: 0.4em;
margin: 0.1em; margin: 0.1em;
font-size: 1.18em; font-size: 1.18em;
border-radius: 5px; border-radius: 5px;
box-shadow: #dfdfdf 0px 0px 1px; box-shadow: #dfdfdf 0 0 1px;
cursor: pointer; cursor: pointer;
&:hover { &:hover {

View File

@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-19 10:43+0100\n" "POT-Creation-Date: 2024-12-21 02:15+0100\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -765,7 +765,7 @@ msgstr "Opération liée : "
#: core/templates/core/create.jinja:12 core/templates/core/edit.jinja:7 #: core/templates/core/create.jinja:12 core/templates/core/edit.jinja:7
#: core/templates/core/edit.jinja:15 core/templates/core/edit.jinja:20 #: core/templates/core/edit.jinja:15 core/templates/core/edit.jinja:20
#: core/templates/core/file_edit.jinja:8 #: core/templates/core/file_edit.jinja:8
#: core/templates/core/macros_pages.jinja:25 #: core/templates/core/macros_pages.jinja:26
#: core/templates/core/page_prop.jinja:11 #: core/templates/core/page_prop.jinja:11
#: core/templates/core/user_godfathers.jinja:61 #: core/templates/core/user_godfathers.jinja:61
#: core/templates/core/user_godfathers_tree.jinja:85 #: core/templates/core/user_godfathers_tree.jinja:85
@ -971,7 +971,7 @@ msgstr "Comptoir"
msgid "Products" msgid "Products"
msgstr "Produits" msgstr "Produits"
#: club/forms.py:168 counter/views/mixins.py:99 #: club/forms.py:168 counter/templates/counter/product_list.jinja:43
msgid "Archived products" msgid "Archived products"
msgstr "Produits archivés" msgstr "Produits archivés"
@ -1146,7 +1146,7 @@ msgid "There are no members in this club."
msgstr "Il n'y a pas de membres dans ce club." msgstr "Il n'y a pas de membres dans ce club."
#: club/templates/club/club_members.jinja:80 #: club/templates/club/club_members.jinja:80
#: core/templates/core/file_detail.jinja:19 core/views/forms.py:305 #: core/templates/core/file_detail.jinja:19 core/views/forms.py:309
#: launderette/views.py:208 trombi/templates/trombi/detail.jinja:19 #: launderette/views.py:208 trombi/templates/trombi/detail.jinja:19
msgid "Add" msgid "Add"
msgstr "Ajouter" msgstr "Ajouter"
@ -1192,6 +1192,7 @@ msgid "Show"
msgstr "Montrer" msgstr "Montrer"
#: club/templates/club/club_sellings.jinja:38 #: club/templates/club/club_sellings.jinja:38
#: counter/templates/counter/product_list.jinja:74
msgid "Download as cvs" msgid "Download as cvs"
msgstr "Télécharger en CSV" msgstr "Télécharger en CSV"
@ -2037,8 +2038,8 @@ msgid ""
"The groups this user belongs to. A user will get all permissions granted to " "The groups this user belongs to. A user will get all permissions granted to "
"each of their groups." "each of their groups."
msgstr "" msgstr ""
"Les groupes auxquels cet utilisateur appartient. Un utilisateur aura toutes les " "Les groupes auxquels cet utilisateur appartient. Un utilisateur aura toutes "
"permissions de chacun de ses groupes." "les permissions de chacun de ses groupes."
#: core/models.py:277 #: core/models.py:277
msgid "profile" msgid "profile"
@ -2478,7 +2479,7 @@ msgstr "Photos"
#: core/templates/core/base/navbar.jinja:22 counter/models.py:491 #: core/templates/core/base/navbar.jinja:22 counter/models.py:491
#: counter/templates/counter/counter_list.jinja:11 #: counter/templates/counter/counter_list.jinja:11
#: eboutic/templates/eboutic/eboutic_main.jinja:4 #: eboutic/templates/eboutic/eboutic_main.jinja:4
#: eboutic/templates/eboutic/eboutic_main.jinja:22 #: eboutic/templates/eboutic/eboutic_main.jinja:23
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:16 #: eboutic/templates/eboutic/eboutic_makecommand.jinja:16
#: eboutic/templates/eboutic/eboutic_payment_result.jinja:4 #: eboutic/templates/eboutic/eboutic_payment_result.jinja:4
#: sith/settings.py:422 sith/settings.py:430 #: sith/settings.py:422 sith/settings.py:430
@ -2763,11 +2764,11 @@ msgstr "Tout désélectionner"
msgid "You're seeing the history of page \"%(page_name)s\"" msgid "You're seeing the history of page \"%(page_name)s\""
msgstr "Vous consultez l'historique de la page \"%(page_name)s\"" msgstr "Vous consultez l'historique de la page \"%(page_name)s\""
#: core/templates/core/macros_pages.jinja:8 #: core/templates/core/macros_pages.jinja:10
msgid "last" msgid "last"
msgstr "actuel" msgstr "actuel"
#: core/templates/core/macros_pages.jinja:21 #: core/templates/core/macros_pages.jinja:22
msgid "Edit page" msgid "Edit page"
msgstr "Éditer la page" msgstr "Éditer la page"
@ -3021,11 +3022,11 @@ msgid "Eboutic invoices"
msgstr "Facture eboutic" msgstr "Facture eboutic"
#: core/templates/core/user_account.jinja:54 #: core/templates/core/user_account.jinja:54
#: core/templates/core/user_tools.jinja:58 counter/views/mixins.py:119 #: core/templates/core/user_tools.jinja:58 counter/views/mixins.py:114
msgid "Etickets" msgid "Etickets"
msgstr "Etickets" msgstr "Etickets"
#: core/templates/core/user_account.jinja:69 core/views/user.py:633 #: core/templates/core/user_account.jinja:69 core/views/user.py:631
msgid "User has no account" msgid "User has no account"
msgstr "L'utilisateur n'a pas de compte" msgstr "L'utilisateur n'a pas de compte"
@ -3325,7 +3326,8 @@ msgstr "Outils utilisateurs"
msgid "Sith management" msgid "Sith management"
msgstr "Gestion de Sith" msgstr "Gestion de Sith"
#: core/templates/core/user_tools.jinja:21 core/views/user.py:254 #: core/templates/core/user_tools.jinja:21 core/views/forms.py:295
#: core/views/user.py:254
msgid "Groups" msgid "Groups"
msgstr "Groupes" msgstr "Groupes"
@ -3372,12 +3374,12 @@ msgstr "Gestion des types de produit"
#: core/templates/core/user_tools.jinja:56 #: core/templates/core/user_tools.jinja:56
#: counter/templates/counter/cash_summary_list.jinja:23 #: counter/templates/counter/cash_summary_list.jinja:23
#: counter/views/mixins.py:109 #: counter/views/mixins.py:104
msgid "Cash register summaries" msgid "Cash register summaries"
msgstr "Relevés de caisse" msgstr "Relevés de caisse"
#: core/templates/core/user_tools.jinja:57 #: core/templates/core/user_tools.jinja:57
#: counter/templates/counter/invoices_call.jinja:4 counter/views/mixins.py:114 #: counter/templates/counter/invoices_call.jinja:4 counter/views/mixins.py:109
msgid "Invoices call" msgid "Invoices call"
msgstr "Appels à facture" msgstr "Appels à facture"
@ -3517,32 +3519,32 @@ msgstr "Blouse : montrez aux autres à quoi ressemble votre blouse !"
msgid "Bad image format, only jpeg, png, webp and gif are accepted" msgid "Bad image format, only jpeg, png, webp and gif are accepted"
msgstr "Mauvais format d'image, seuls les jpeg, png, webp et gif sont acceptés" msgstr "Mauvais format d'image, seuls les jpeg, png, webp et gif sont acceptés"
#: core/views/forms.py:302 #: core/views/forms.py:306
msgid "Godfather / Godmother" msgid "Godfather / Godmother"
msgstr "Parrain / Marraine" msgstr "Parrain / Marraine"
#: core/views/forms.py:303 #: core/views/forms.py:307
msgid "Godchild" msgid "Godchild"
msgstr "Fillot / Fillote" msgstr "Fillot / Fillote"
#: core/views/forms.py:308 counter/forms.py:78 trombi/views.py:151 #: core/views/forms.py:312 counter/forms.py:78 trombi/views.py:151
msgid "Select user" msgid "Select user"
msgstr "Choisir un utilisateur" msgstr "Choisir un utilisateur"
#: core/views/forms.py:322 #: core/views/forms.py:326
msgid "This user does not exist" msgid "This user does not exist"
msgstr "Cet utilisateur n'existe pas" msgstr "Cet utilisateur n'existe pas"
#: core/views/forms.py:324 #: core/views/forms.py:328
msgid "You cannot be related to yourself" msgid "You cannot be related to yourself"
msgstr "Vous ne pouvez pas être relié à vous-même" msgstr "Vous ne pouvez pas être relié à vous-même"
#: core/views/forms.py:336 #: core/views/forms.py:340
#, python-format #, python-format
msgid "%s is already your godfather" msgid "%s is already your godfather"
msgstr "%s est déjà votre parrain/marraine" msgstr "%s est déjà votre parrain/marraine"
#: core/views/forms.py:342 #: core/views/forms.py:346
#, python-format #, python-format
msgid "%s is already your godchild" msgid "%s is already your godchild"
msgstr "%s est déjà votre fillot/fillote" msgstr "%s est déjà votre fillot/fillote"
@ -4150,23 +4152,39 @@ msgstr ""
"votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura " "votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura "
"aucune conséquence autre que le retrait de l'argent de votre compte." "aucune conséquence autre que le retrait de l'argent de votre compte."
#: counter/templates/counter/product_list.jinja:4 #: counter/templates/counter/product_list.jinja:5
#: counter/templates/counter/product_list.jinja:11 #: counter/templates/counter/product_list.jinja:62
msgid "Product list" msgid "Product list"
msgstr "Liste des produits" msgstr "Liste des produits"
#: counter/templates/counter/product_list.jinja:9 #: counter/templates/counter/product_list.jinja:22
msgid "Filter products"
msgstr "Filtrer les produits"
#: counter/templates/counter/product_list.jinja:27
msgid "Product name"
msgstr "Nom du produit"
#: counter/templates/counter/product_list.jinja:36
msgid "Product state"
msgstr "Etat du produit"
#: counter/templates/counter/product_list.jinja:39
msgid "Active products"
msgstr "Produits actifs"
#: counter/templates/counter/product_list.jinja:47
msgid "All products"
msgstr "Tous les produits"
#: counter/templates/counter/product_list.jinja:52
msgid "Product type"
msgstr "Type de produit"
#: counter/templates/counter/product_list.jinja:66
msgid "New product" msgid "New product"
msgstr "Nouveau produit" msgstr "Nouveau produit"
#: counter/templates/counter/product_list.jinja:13
msgid "Uncategorized"
msgstr "Sans catégorie"
#: counter/templates/counter/product_list.jinja:20
msgid "There is no products in this website."
msgstr "Il n'y a pas de produits dans ce site web."
#: counter/templates/counter/product_type_list.jinja:4 #: counter/templates/counter/product_type_list.jinja:4
#: counter/templates/counter/product_type_list.jinja:42 #: counter/templates/counter/product_type_list.jinja:42
msgid "Product type list" msgid "Product type list"
@ -4327,7 +4345,7 @@ msgstr "Dernières opérations"
msgid "Counter administration" msgid "Counter administration"
msgstr "Administration des comptoirs" msgstr "Administration des comptoirs"
#: counter/views/mixins.py:104 #: counter/views/mixins.py:99
msgid "Product types" msgid "Product types"
msgstr "Types de produit" msgstr "Types de produit"
@ -4373,26 +4391,26 @@ msgstr "id du type du produit"
msgid "basket" msgid "basket"
msgstr "panier" msgstr "panier"
#: eboutic/templates/eboutic/eboutic_main.jinja:39 #: eboutic/templates/eboutic/eboutic_main.jinja:40
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:44 #: eboutic/templates/eboutic/eboutic_makecommand.jinja:44
msgid "Current account amount: " msgid "Current account amount: "
msgstr "Solde actuel : " msgstr "Solde actuel : "
#: eboutic/templates/eboutic/eboutic_main.jinja:58 #: eboutic/templates/eboutic/eboutic_main.jinja:59
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:40 #: eboutic/templates/eboutic/eboutic_makecommand.jinja:40
msgid "Basket amount: " msgid "Basket amount: "
msgstr "Valeur du panier : " msgstr "Valeur du panier : "
#: eboutic/templates/eboutic/eboutic_main.jinja:65 #: eboutic/templates/eboutic/eboutic_main.jinja:66
msgid "Clear" msgid "Clear"
msgstr "Vider" msgstr "Vider"
#: eboutic/templates/eboutic/eboutic_main.jinja:71 #: eboutic/templates/eboutic/eboutic_main.jinja:72
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:95 #: eboutic/templates/eboutic/eboutic_makecommand.jinja:95
msgid "Validate" msgid "Validate"
msgstr "Valider" msgstr "Valider"
#: eboutic/templates/eboutic/eboutic_main.jinja:80 #: eboutic/templates/eboutic/eboutic_main.jinja:81
msgid "" msgid ""
"You have not filled in your date of birth. As a result, you may not have " "You have not filled in your date of birth. As a result, you may not have "
"access to all the products in the online shop. To fill in your date of " "access to all the products in the online shop. To fill in your date of "
@ -4403,11 +4421,11 @@ msgstr ""
"boutique en ligne. Pour remplir votre date de naissance, vous pouvez aller " "boutique en ligne. Pour remplir votre date de naissance, vous pouvez aller "
"sur" "sur"
#: eboutic/templates/eboutic/eboutic_main.jinja:82 #: eboutic/templates/eboutic/eboutic_main.jinja:83
msgid "this page" msgid "this page"
msgstr "cette page" msgstr "cette page"
#: eboutic/templates/eboutic/eboutic_main.jinja:128 #: eboutic/templates/eboutic/eboutic_main.jinja:132
msgid "There are no items available for sale" msgid "There are no items available for sale"
msgstr "Aucun article n'est disponible à la vente" msgstr "Aucun article n'est disponible à la vente"

View File

@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-17 00:46+0100\n" "POT-Creation-Date: 2024-12-18 16:26+0100\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -122,11 +122,43 @@ msgstr "photos.%(extension)s"
msgid "captured.%s" msgid "captured.%s"
msgstr "capture.%s" msgstr "capture.%s"
#: counter/static/bundled/counter/product-type-index.ts:36 #: counter/static/bundled/counter/product-list-index.ts:39
msgid "name"
msgstr "nom"
#: counter/static/bundled/counter/product-list-index.ts:42
msgid "product type"
msgstr "type de produit"
#: counter/static/bundled/counter/product-list-index.ts:44
msgid "limit age"
msgstr "limite d'âge"
#: counter/static/bundled/counter/product-list-index.ts:45
msgid "purchase price"
msgstr "prix d'achat"
#: counter/static/bundled/counter/product-list-index.ts:46
msgid "selling price"
msgstr "prix de vente"
#: counter/static/bundled/counter/product-list-index.ts:47
msgid "archived"
msgstr "archivé"
#: counter/static/bundled/counter/product-list-index.ts:116
msgid "Uncategorized"
msgstr "Sans catégorie"
#: counter/static/bundled/counter/product-list-index.ts:134
msgid "products.csv"
msgstr "produits.csv"
#: counter/static/bundled/counter/product-type-index.ts:46
msgid "Products types reordered!" msgid "Products types reordered!"
msgstr "Types de produits réordonnés !" msgstr "Types de produits réordonnés !"
#: counter/static/bundled/counter/product-type-index.ts:40 #: counter/static/bundled/counter/product-type-index.ts:50
#, javascript-format #, javascript-format
msgid "Product type reorganisation failed with status code : %d" msgid "Product type reorganisation failed with status code : %d"
msgstr "La réorganisation des types de produit a échoué avec le code : %d" msgstr "La réorganisation des types de produit a échoué avec le code : %d"

View File

@ -1,3 +1,5 @@
@import "core/static/core/colors";
main { main {
box-sizing: border-box; box-sizing: border-box;
padding: 10px; padding: 10px;
@ -25,7 +27,7 @@ main {
font-size: 1.2em; font-size: 1.2em;
line-height: 1.2em; line-height: 1.2em;
color: black; color: black;
background-color: #f2f2f2; background-color: $primary-neutral-light-color;
border-radius: 5px; border-radius: 5px;
font-weight: bold; font-weight: bold;
@ -34,7 +36,7 @@ main {
} }
&:disabled { &:disabled {
background-color: #f2f2f2; background-color: $primary-neutral-light-color;
color: #d4d4d4; color: #d4d4d4;
} }
} }

View File

@ -1,3 +1,5 @@
@import "core/static/core/colors";
#content { #content {
padding: 10px !important; padding: 10px !important;
} }
@ -241,7 +243,7 @@
>div { >div {
>a.button { >a.button {
box-sizing: border-box; box-sizing: border-box;
background-color: #f2f2f2; background-color: $primary-neutral-light-color;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;