mirror of
https://github.com/ae-utbm/sith.git
synced 2024-12-22 07:41:14 +00:00
Merge pull request #946 from ae-utbm/product-csv
Rework the product admin page
This commit is contained in:
commit
6d02970676
@ -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 ?? {};
|
||||
|
49
core/static/bundled/utils/csv.ts
Normal file
49
core/static/bundled/utils/csv.ts
Normal 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
37
core/static/bundled/utils/types.d.ts
vendored
Normal 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;
|
96
core/static/core/components/card.scss
Normal file
96
core/static/core/components/card.scss
Normal 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
|
||||
}
|
||||
}
|
||||
|
@ -107,7 +107,7 @@ form {
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
label, legend {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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 #}
|
||||
|
@ -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";
|
||||
|
163
counter/static/bundled/counter/product-list-index.ts
Normal file
163
counter/static/bundled/counter/product-list-index.ts
Normal 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;
|
||||
},
|
||||
}));
|
||||
});
|
@ -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(
|
||||
|
11
counter/static/counter/css/admin.scss
Normal file
11
counter/static/counter/css/admin.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
|
@ -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 %}
|
||||
|
@ -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>/",
|
||||
|
@ -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):
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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"
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user