mirror of
https://github.com/ae-utbm/sith.git
synced 2024-12-23 00:01:16 +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
|
// 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 ?? {};
|
||||||
|
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;
|
display: block;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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 #}
|
||||||
|
@ -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";
|
||||||
|
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) {
|
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(
|
||||||
|
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" %}
|
{% 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 %}
|
||||||
|
@ -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>/",
|
||||||
|
@ -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):
|
||||||
|
@ -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",
|
||||||
|
@ -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
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -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>
|
||||||
|
@ -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 {
|
||||||
|
@ -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"
|
||||||
|
|
||||||
|
@ -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"
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
Loading…
Reference in New Issue
Block a user