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
// please test this function. A all cost.
/**
* Load complete dataset from paginated routes.
*/
export const paginated = async <T>(
endpoint: PaginatedEndpoint<T>,
options?: PaginatedRequest,
) => {
): Promise<T[]> => {
const maxPerPage = 199;
const queryParams = options ?? {};
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;
margin-bottom: 8px;

View File

@ -19,6 +19,13 @@ body {
--loading-stroke: 5px;
--loading-duration: 1s;
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 {
@ -198,6 +205,9 @@ body {
margin: 20px auto 0;
/*---------------------------------NAV---------------------------------*/
a.btn {
display: inline-block;
}
.btn {
font-size: 15px;
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------------------------------*/
#quick_notif {
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 {
display: flex;

View File

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

View File

@ -140,7 +140,7 @@
nb_page (str): call to a javascript function or variable returning
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,
clicking on the pagination buttons could submit the picture management form
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 CounterSchema,
type ProductTypeSchema,
type SimpleProductSchema,
counterSearchCounter,
productSearchProducts,
producttypeFetchAll,
} from "#openapi";
@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")
export class CounterAjaxSelect extends AjaxSelect {
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) {
if (response.ok) {
this.alertMessage.success = true;
this.alertMessage.content = gettext("Products types successfully reordered");
this.alertMessage.content = gettext("Products types reordered!");
} else {
this.alertMessage.success = false;
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" %}
{% from "core/macros.jinja" import paginate_alpine %}
{% block title %}
{% trans %}Product list{% endtrans %}
{% endblock %}
{% block content %}
{% if current_tab == "products" %}
<p><a href="{{ url('counter:new_product') }}">{% trans %}New product{% endtrans %}</a></p>
{% 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 -%}
{% block additional_js %}
<script type="module" src="{{ static("bundled/counter/components/ajax-select-index.ts") }}"></script>
<script type="module" src="{{ static("bundled/counter/product-list-index.ts") }}"></script>
{% 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 counter.views.admin import (
ActiveProductListView,
ArchivedProductListView,
CounterCreateView,
CounterDeleteView,
CounterEditPropView,
@ -27,6 +25,7 @@ from counter.views.admin import (
CounterStatView,
ProductCreateView,
ProductEditView,
ProductListView,
ProductTypeCreateView,
ProductTypeEditView,
ProductTypeListView,
@ -108,12 +107,7 @@ urlpatterns = [
CashSummaryEditView.as_view(),
name="cash_summary_edit",
),
path("admin/product/list/", ActiveProductListView.as_view(), name="product_list"),
path(
"admin/product/list_archived/",
ArchivedProductListView.as_view(),
name="product_list_archived",
),
path("admin/product/", ProductListView.as_view(), name="product_list"),
path("admin/product/create/", ProductCreateView.as_view(), name="new_product"),
path(
"admin/product/<int:product_id>/",

View File

@ -12,19 +12,16 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
import itertools
from datetime import timedelta
from operator import itemgetter
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.db.models import F
from django.forms import CheckboxSelectMultiple
from django.forms.models import modelform_factory
from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy
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 core.utils import get_semester_code, get_start_of_semester
@ -125,40 +122,9 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
current_tab = "products"
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
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."""
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
current_tab = "products"
def get_queryset(self):
return super().get_queryset().filter(archived=False)
template_name = "counter/product_list.jinja"
class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):

View File

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

View File

@ -1,8 +1,12 @@
from pydantic import TypeAdapter
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
from counter.models import Counter, Product
from counter.schemas import SimpleProductSchema, SimplifiedCounterSchema
from counter.models import Counter, Product, ProductType
from counter.schemas import (
ProductTypeSchema,
SimpleProductSchema,
SimplifiedCounterSchema,
)
_js = ["bundled/counter/components/ajax-select-index.ts"]
@ -33,3 +37,17 @@ class AutoCompleteSelectMultipleProduct(AutoCompleteSelectMultiple):
model = Product
adapter = TypeAdapter(list[SimpleProductSchema])
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;
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 {
animation: bg-in-out 1s ease;
background-color: rgb(216, 236, 255);
}
#eboutic .product-button.selected::after {
#eboutic .card.selected::after {
content: "🛒";
position: absolute;
top: 5px;
@ -144,36 +124,6 @@
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 {
display: flex;
justify-content: center;
@ -207,39 +157,5 @@
justify-content: space-around;
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 %}
{% 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 %}
{% block content %}
@ -104,18 +105,21 @@
{% for p in items %}
<button
id="{{ p.id }}"
class="product-button"
class="card product-button clickable shadow"
:class="{selected: items.some((i) => i.id === {{ p.id }})}"
@click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
>
{% if p.icon %}
<img class="product-image" src="{{ p.icon.url }}"
alt="image de {{ p.name }}">
<img
class="card-image"
src="{{ p.icon.url }}"
alt="image de {{ p.name }}"
>
{% else %}
<i class="fa-regular fa-image fa-2x product-image"></i>
<i class="fa-regular fa-image fa-2x card-image"></i>
{% endif %}
<div class="product-description">
<h4>{{ p.name }}</h4>
<div class="card-content">
<h4 class="card-title">{{ p.name }}</h4>
<p>{{ p.selling_price }} €</p>
</div>
</button>

View File

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

View File

@ -6,7 +6,7 @@
msgid ""
msgstr ""
"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"
"Last-Translator: Maréchal <thomas.girod@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/edit.jinja:15 core/templates/core/edit.jinja:20
#: 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/user_godfathers.jinja:61
#: core/templates/core/user_godfathers_tree.jinja:85
@ -971,7 +971,7 @@ msgstr "Comptoir"
msgid "Products"
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"
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."
#: 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
msgid "Add"
msgstr "Ajouter"
@ -1192,6 +1192,7 @@ msgid "Show"
msgstr "Montrer"
#: club/templates/club/club_sellings.jinja:38
#: counter/templates/counter/product_list.jinja:74
msgid "Download as cvs"
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 "
"each of their groups."
msgstr ""
"Les groupes auxquels cet utilisateur appartient. Un utilisateur aura toutes les "
"permissions de chacun de ses groupes."
"Les groupes auxquels cet utilisateur appartient. Un utilisateur aura toutes "
"les permissions de chacun de ses groupes."
#: core/models.py:277
msgid "profile"
@ -2478,7 +2479,7 @@ msgstr "Photos"
#: core/templates/core/base/navbar.jinja:22 counter/models.py:491
#: counter/templates/counter/counter_list.jinja:11
#: 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_payment_result.jinja:4
#: 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\""
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"
msgstr "actuel"
#: core/templates/core/macros_pages.jinja:21
#: core/templates/core/macros_pages.jinja:22
msgid "Edit page"
msgstr "Éditer la page"
@ -3021,11 +3022,11 @@ msgid "Eboutic invoices"
msgstr "Facture eboutic"
#: 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"
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"
msgstr "L'utilisateur n'a pas de compte"
@ -3325,7 +3326,8 @@ msgstr "Outils utilisateurs"
msgid "Sith management"
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"
msgstr "Groupes"
@ -3372,12 +3374,12 @@ msgstr "Gestion des types de produit"
#: core/templates/core/user_tools.jinja:56
#: counter/templates/counter/cash_summary_list.jinja:23
#: counter/views/mixins.py:109
#: counter/views/mixins.py:104
msgid "Cash register summaries"
msgstr "Relevés de caisse"
#: 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"
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"
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"
msgstr "Parrain / Marraine"
#: core/views/forms.py:303
#: core/views/forms.py:307
msgid "Godchild"
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"
msgstr "Choisir un utilisateur"
#: core/views/forms.py:322
#: core/views/forms.py:326
msgid "This user does not exist"
msgstr "Cet utilisateur n'existe pas"
#: core/views/forms.py:324
#: core/views/forms.py:328
msgid "You cannot be related to yourself"
msgstr "Vous ne pouvez pas être relié à vous-même"
#: core/views/forms.py:336
#: core/views/forms.py:340
#, python-format
msgid "%s is already your godfather"
msgstr "%s est déjà votre parrain/marraine"
#: core/views/forms.py:342
#: core/views/forms.py:346
#, python-format
msgid "%s is already your godchild"
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 "
"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:11
#: counter/templates/counter/product_list.jinja:5
#: counter/templates/counter/product_list.jinja:62
msgid "Product list"
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"
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:42
msgid "Product type list"
@ -4327,7 +4345,7 @@ msgstr "Dernières opérations"
msgid "Counter administration"
msgstr "Administration des comptoirs"
#: counter/views/mixins.py:104
#: counter/views/mixins.py:99
msgid "Product types"
msgstr "Types de produit"
@ -4373,26 +4391,26 @@ msgstr "id du type du produit"
msgid "basket"
msgstr "panier"
#: eboutic/templates/eboutic/eboutic_main.jinja:39
#: eboutic/templates/eboutic/eboutic_main.jinja:40
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:44
msgid "Current account amount: "
msgstr "Solde actuel : "
#: eboutic/templates/eboutic/eboutic_main.jinja:58
#: eboutic/templates/eboutic/eboutic_main.jinja:59
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:40
msgid "Basket amount: "
msgstr "Valeur du panier : "
#: eboutic/templates/eboutic/eboutic_main.jinja:65
#: eboutic/templates/eboutic/eboutic_main.jinja:66
msgid "Clear"
msgstr "Vider"
#: eboutic/templates/eboutic/eboutic_main.jinja:71
#: eboutic/templates/eboutic/eboutic_main.jinja:72
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:95
msgid "Validate"
msgstr "Valider"
#: eboutic/templates/eboutic/eboutic_main.jinja:80
#: eboutic/templates/eboutic/eboutic_main.jinja:81
msgid ""
"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 "
@ -4403,11 +4421,11 @@ msgstr ""
"boutique en ligne. Pour remplir votre date de naissance, vous pouvez aller "
"sur"
#: eboutic/templates/eboutic/eboutic_main.jinja:82
#: eboutic/templates/eboutic/eboutic_main.jinja:83
msgid "this 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"
msgstr "Aucun article n'est disponible à la vente"

View File

@ -7,7 +7,7 @@
msgid ""
msgstr ""
"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"
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@ -122,11 +122,43 @@ msgstr "photos.%(extension)s"
msgid "captured.%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!"
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
msgid "Product type reorganisation failed with status 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 {
box-sizing: border-box;
padding: 10px;
@ -25,7 +27,7 @@ main {
font-size: 1.2em;
line-height: 1.2em;
color: black;
background-color: #f2f2f2;
background-color: $primary-neutral-light-color;
border-radius: 5px;
font-weight: bold;
@ -34,7 +36,7 @@ main {
}
&:disabled {
background-color: #f2f2f2;
background-color: $primary-neutral-light-color;
color: #d4d4d4;
}
}

View File

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