feat: add sale flow with Boopos Cloud
This commit is contained in:
parent
0a14fe8882
commit
9cb4302fc9
19 changed files with 449 additions and 398 deletions
310
package/components/organisms/Checkout/ClientSearch.tsx
Normal file
310
package/components/organisms/Checkout/ClientSearch.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
'use client';
|
||||
import { Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Paper, Typography } from '@mui/material';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
import { useAtom } from 'jotai';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Button from '@atoms/Button';
|
||||
import CustomLoading from '@atoms/CustomLoading';
|
||||
import Icon from '@atoms/Icon';
|
||||
import Input from '@atoms/Input';
|
||||
import Label from '@atoms/Label';
|
||||
import { storeNumberAtom, storePosAtom } from '@atoms/states/StoreAtom';
|
||||
import Title from '@atoms/Title';
|
||||
import { Tooltip } from '@atoms/Tooltip';
|
||||
import { useClientSearchLogic } from '@hooks/useClientSearchLogic';
|
||||
import { CustomerInterface } from '@interfaces/ResponseInterfaces/Customer/CustomerResponseInterface';
|
||||
import { CustomerDniTypeInterface } from '@interfaces/ResponseInterfaces/Customer/DNITypeResponseInterface';
|
||||
import { StartSaleResponseInterface } from '@interfaces/ResponseInterfaces/Sale/StartSaleResponseInterface';
|
||||
import StartSale from '@package/config/api/Sale/Transaction/StartSale/page';
|
||||
import { getUser } from '@utils/Utilities';
|
||||
|
||||
interface ClientSearchProps {
|
||||
onTransactionComplete: (transaction: StartSaleResponseInterface, client: CustomerInterface) => void;
|
||||
}
|
||||
|
||||
const ClientSearch: React.FC<ClientSearchProps> = ({ onTransactionComplete }) => {
|
||||
const {
|
||||
idTypes,
|
||||
loading,
|
||||
error,
|
||||
result,
|
||||
formData,
|
||||
handleInputChange,
|
||||
handleSubmitForm,
|
||||
isAffiliated,
|
||||
customer,
|
||||
} = useClientSearchLogic();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const [idUser] = useState(getUser() && getUser().id ? getUser().id : '');
|
||||
|
||||
const [storeNumber] = useAtom(storeNumberAtom);
|
||||
const [storePos] = useAtom(storePosAtom);
|
||||
|
||||
const handleOpenDialog = () => {
|
||||
setOpenDialog(true);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setOpenDialog(false);
|
||||
};
|
||||
|
||||
const handleUseGenericCustomer = async () => {
|
||||
const genericCustomer = {
|
||||
id: '',
|
||||
idCompany: 'default-company',
|
||||
companyName: 'default-company-name',
|
||||
idSubsidiary: 3,
|
||||
nameSubsidiary: 'default-subsidiary',
|
||||
externalClientId: null,
|
||||
name: 'Cliente',
|
||||
secondName: '',
|
||||
firstSurname: 'Generico',
|
||||
secondSurname: '',
|
||||
idTypeCustomer: '12',
|
||||
nameTypeCustomer: 'Tarjeta de identidad',
|
||||
dni: '222222222222',
|
||||
idGender: '',
|
||||
nameGender: '',
|
||||
idMaritalStatus: null,
|
||||
nameMaritalStatus: null,
|
||||
address: '',
|
||||
idCity: null,
|
||||
nameCity: null,
|
||||
idCountry: null,
|
||||
nameCountry: null,
|
||||
mail: '',
|
||||
phone: '',
|
||||
idUser: '',
|
||||
nameUser: '',
|
||||
status: true,
|
||||
birthdate: null,
|
||||
group: [],
|
||||
NameSurname: '',
|
||||
SurnameName: ''
|
||||
};
|
||||
|
||||
const saleData = {
|
||||
storeNumber: storeNumber,
|
||||
idSubsidiary: genericCustomer.idSubsidiary,
|
||||
idClient: genericCustomer.id,
|
||||
clientDni: genericCustomer.dni,
|
||||
idType: genericCustomer.idTypeCustomer,
|
||||
idGroup: '',
|
||||
invoiceName: genericCustomer.name,
|
||||
invoiceTypeNit: genericCustomer.idTypeCustomer,
|
||||
invoiceNit: genericCustomer.dni,
|
||||
invoiceDirection: genericCustomer.address,
|
||||
invoiceCellPhone: genericCustomer.phone,
|
||||
invoiceEmail: genericCustomer.mail,
|
||||
idUser: idUser,
|
||||
localDate: new Date().toISOString(),
|
||||
pos: storePos,
|
||||
};
|
||||
|
||||
const saleResponse = await StartSale(saleData) as StartSaleResponseInterface;
|
||||
|
||||
if (saleResponse && saleResponse.correct) {
|
||||
if (customer) {
|
||||
onTransactionComplete(saleResponse, customer);
|
||||
} else {
|
||||
console.error("Customer is null");
|
||||
}
|
||||
} else {
|
||||
console.error(saleResponse.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
handleSubmitForm();
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (customer) {
|
||||
const saleData = {
|
||||
storeNumber: storeNumber,
|
||||
idSubsidiary: customer.idSubsidiary || 0,
|
||||
idClient: customer.id,
|
||||
clientDni: customer.dni,
|
||||
idType: customer.idTypeCustomer,
|
||||
idGroup: '',
|
||||
invoiceName: customer.name,
|
||||
invoiceTypeNit: customer.idTypeCustomer,
|
||||
invoiceNit: customer.dni,
|
||||
invoiceDirection: customer.address,
|
||||
invoiceCellPhone: customer.phone,
|
||||
invoiceEmail: customer.mail,
|
||||
idUser: idUser,
|
||||
localDate: new Date().toISOString(),
|
||||
pos: storePos,
|
||||
};
|
||||
console.log("VALOR DE SALEDATA",saleData);
|
||||
const saleResponse = await StartSale(saleData) as StartSaleResponseInterface;
|
||||
|
||||
console.log("VALOR DE SALESRESPONSE",saleResponse);
|
||||
if (saleResponse && saleResponse.correct) {
|
||||
onTransactionComplete(saleResponse, customer);
|
||||
} else {
|
||||
console.error(saleResponse.message);
|
||||
}
|
||||
} else {
|
||||
handleOpenDialog();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper elevation={3} className="p-6 max-w-2xl mx-auto">
|
||||
<Title text={t("lbl_client_search")} size="h2" />
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<Label htmlFor="idType" text={t("lbl_id_type")} />
|
||||
<select
|
||||
name="idType"
|
||||
value={formData.idType}
|
||||
onChange={handleInputChange}
|
||||
className="w-full p-2 border rounded"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">{t("lbl_selectid_type")}</option>
|
||||
{idTypes.length > 0 ? (
|
||||
idTypes.map((type: CustomerDniTypeInterface) => (
|
||||
<option key={type.id} value={type.id}>
|
||||
{type.name}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option disabled>{t("msj_loading_id_types")}</option>
|
||||
)}
|
||||
</select>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Label htmlFor="idNumber" text={t("lbl_id_number")} />
|
||||
<Input
|
||||
type="text"
|
||||
name="idNumber"
|
||||
placeholder={t("msj_enter_id_number")}
|
||||
value={formData.idNumber}
|
||||
onChange={handleInputChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Tooltip explainer={t("mjs_search_for_client")} withArrow>
|
||||
<Button
|
||||
label={t("lbl_search")}
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
startIcon={<Icon name="Search" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center mt-4">
|
||||
<CustomLoading />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Typography color="error" className="mt-4">
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{result.length === 0 && !isAffiliated && (
|
||||
<Paper elevation={2} className="mt-6 p-4">
|
||||
<Title text={t("msj_no_affiliate")} size="h3" />
|
||||
<Typography variant="body1" className="mt-2">
|
||||
{t("msj_choose_option_for_non_affiliate")}
|
||||
</Typography>
|
||||
<div className="flex justify-center gap-4 mt-4">
|
||||
<Button
|
||||
label={t("lbl_use_generic_customer")}
|
||||
variant="primary"
|
||||
onClick={handleOpenDialog}
|
||||
/>
|
||||
<Button
|
||||
label={t("lbl_create_new_customer")}
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{result.length > 0 && (
|
||||
<Paper elevation={2} className="mt-6 p-4">
|
||||
<Title text={t("lbl_search_result")} size="h3" />
|
||||
{result.map((customer) => (
|
||||
<Grid container spacing={2} className="mt-2" key={customer.id}>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<Typography variant="body1">
|
||||
<strong>{t("lbl_name")}:</strong> {customer.name}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<Typography variant="body1">
|
||||
<strong>{t("lbl_last_name")}:</strong> {customer.firstSurname}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<Typography variant="body1">
|
||||
<strong>{t("lbl_email")}:</strong> {customer.mail}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<Typography variant="body1">
|
||||
<strong>{t("lbl_phone")}:</strong> {customer.phone}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Typography variant="body1">
|
||||
<strong>{t("lbl_address")}:</strong> {customer.address}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
))}
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button
|
||||
label={t("lbl_continue")}
|
||||
variant="primary"
|
||||
onClick={handleContinue}
|
||||
/>
|
||||
</div>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ textAlign: 'center' }}>
|
||||
{t("msj_confirm_generic_customer")}
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
|
||||
<DialogContentText>
|
||||
{t("msj_are_you_sure_generic_customer")}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ justifyContent: 'center' }}>
|
||||
<Button onClick={handleCloseDialog} label={t("lbl_cancel")} />
|
||||
<Button
|
||||
onClick={handleUseGenericCustomer}
|
||||
label={t("lbl_confirm")}
|
||||
variant="primary"
|
||||
/>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientSearch;
|
||||
|
|
@ -1,32 +1,78 @@
|
|||
"use client";
|
||||
|
||||
import Grid2 from '@mui/material/Grid2';
|
||||
import React from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import React, { useState } from 'react';
|
||||
import { storeNumberAtom, storePosAtom } from '@atoms/states/StoreAtom';
|
||||
import { AddProductsOutputInterface } from '@interfaces/OutputInterfaces/Sale/AddProductsOutputInterfae';
|
||||
import { CustomerInterface } from '@interfaces/ResponseInterfaces/Customer/CustomerResponseInterface';
|
||||
import { AddProductsResponseInterface, ItemObject } from '@interfaces/ResponseInterfaces/Sale/AddProductsResponseInterface';
|
||||
import { StartSaleResponseInterface } from '@interfaces/ResponseInterfaces/Sale/StartSaleResponseInterface';
|
||||
import ActionButtons from '@molecules/ActionButtons';
|
||||
import ProductSearch from '@molecules/ProductSearch';
|
||||
import SaleSummary from '@molecules/SaleSummary';
|
||||
import AddProducts from "@package/config/api/Sale/Transaction/AddProducts/page";
|
||||
|
||||
interface ControlPanelProps {
|
||||
onVoidOrder: () => void;
|
||||
onVoidLast: () => void;
|
||||
onResume: () => void;
|
||||
onSearchPLU: (plu: string) => void;
|
||||
subtotal: number;
|
||||
discount: number;
|
||||
tax: number;
|
||||
total: number;
|
||||
transaction: StartSaleResponseInterface;
|
||||
client: CustomerInterface;
|
||||
onProductAdded: () => void;
|
||||
}
|
||||
|
||||
const ControlPanel: React.FC<ControlPanelProps> = ({
|
||||
onVoidOrder,
|
||||
onVoidLast,
|
||||
onResume,
|
||||
onSearchPLU,
|
||||
subtotal,
|
||||
discount,
|
||||
tax,
|
||||
total
|
||||
transaction,
|
||||
client,
|
||||
onProductAdded,
|
||||
}) => {
|
||||
|
||||
const [addedProducts, setAddedProducts] = useState<ItemObject>({} as ItemObject);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [storeNumber] = useAtom(storeNumberAtom);
|
||||
const [storePos] = useAtom(storePosAtom);
|
||||
|
||||
|
||||
const handleSearchSKU = async (sku: string) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const addProductRequest: AddProductsOutputInterface = {
|
||||
idTransaction: transaction.object?.id,
|
||||
storeNumber: storeNumber,
|
||||
idSubsidiary: client.idSubsidiary,
|
||||
clientNit: client.dni,
|
||||
idUser: client.idUser,
|
||||
localDate: new Date(),
|
||||
pos: storePos,
|
||||
products: [
|
||||
{
|
||||
sku: sku,
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const productResponse = await AddProducts(addProductRequest) as AddProductsResponseInterface;
|
||||
|
||||
if (productResponse && productResponse.correct) {
|
||||
const newProduct = productResponse.object;
|
||||
setAddedProducts(newProduct)
|
||||
onProductAdded();
|
||||
} else {
|
||||
console.error("Error adding product: ", productResponse?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during AddProducts API call", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid2 container spacing={4}>
|
||||
<Grid2 size={{xs:12}}>
|
||||
|
|
@ -34,11 +80,11 @@ const ControlPanel: React.FC<ControlPanelProps> = ({
|
|||
</Grid2>
|
||||
|
||||
<Grid2 size={{xs:12}}>
|
||||
<ProductSearch onSearch={onSearchPLU} />
|
||||
<ProductSearch onSearch={handleSearchSKU} />
|
||||
</Grid2>
|
||||
|
||||
<Grid2 size={{xs:12}}>
|
||||
<SaleSummary subtotal={subtotal} discount={discount} tax={tax} total={total} />
|
||||
<SaleSummary product={addedProducts} />
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,30 +3,16 @@
|
|||
import { Box, Table, TableBody, TableCell, TableHead, TableRow } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SaleTransactionObjectInterface } from '@interfaces/ResponseInterfaces/Sale/GetSaleResponseInterface';
|
||||
import CustomCard from '@molecules/CustomCard';
|
||||
import UserHeader from '@molecules/UserHeader';
|
||||
|
||||
interface Product {
|
||||
id: number;
|
||||
amount: number;
|
||||
name: string;
|
||||
unitValue: number;
|
||||
tax: number;
|
||||
discount: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ProductTableProps {
|
||||
products: Product[];
|
||||
products: SaleTransactionObjectInterface | null;
|
||||
}
|
||||
|
||||
const ProductTable: React.FC<ProductTableProps> = ({ products }) => {
|
||||
const totalAmount = products.reduce((acc, product) => acc + product.amount, 0);
|
||||
const totalUnitValue = products.reduce((acc, product) => acc + product.unitValue * product.amount, 0);
|
||||
const totalTax = products.reduce((acc, product) => acc + product.tax, 0);
|
||||
const totalDiscount = products.reduce((acc, product) => acc + product.discount, 0);
|
||||
const grandTotal = products.reduce((acc, product) => acc + product.total, 0);
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
|
|
@ -48,38 +34,60 @@ const ProductTable: React.FC<ProductTableProps> = ({ products }) => {
|
|||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{products.map((product) => (
|
||||
<TableRow key={product.id}>
|
||||
<TableCell>{product.amount}</TableCell>
|
||||
<TableCell>{product.name}</TableCell>
|
||||
<TableCell>${product.unitValue.toFixed(2)}</TableCell>
|
||||
<TableCell>${product.tax.toFixed(2)}</TableCell>
|
||||
<TableCell>${product.discount.toFixed(2)}</TableCell>
|
||||
<TableCell>${product.total.toFixed(2)}</TableCell>
|
||||
{products?.list?.length ? (
|
||||
products.list.map((product) => (
|
||||
<TableRow key={product.idDetail}>
|
||||
<TableCell>{product.quantity}</TableCell>
|
||||
<TableCell>{product.name}</TableCell>
|
||||
<TableCell>${product.regularUnitPrice.toFixed(2)}</TableCell>
|
||||
<TableCell>${product.totalTaxes.toFixed(2)}</TableCell>
|
||||
<TableCell>${product.discount.toFixed(2)}</TableCell>
|
||||
<TableCell>${product.extendedPrice.toFixed(2)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center">
|
||||
{t("msj_no_products_added")} {/* Mensaje de no productos */}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
<Box sx={{ p:0.2, boxShadow: '2', }}>
|
||||
<Box sx={{ p: 0.2, boxShadow: '2' }}>
|
||||
<CustomCard className="bg-gray-700 text-white">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ color: 'RGB(255,255,255)', borderColor: 'with', fontWeight: 'bold', width: '20%' }}>{t("tle_item_count")}</TableCell>
|
||||
<TableCell sx={{ color: 'RGB(255,255,255)', borderColor: 'with', fontWeight: 'bold', width: '20%' }}>{t("tle_total_unit")}</TableCell>
|
||||
<TableCell sx={{ color: 'RGB(255,255,255)', borderColor: 'with', fontWeight: 'bold', width: '20%' }}>{t("tle_total_taxes")}</TableCell>
|
||||
<TableCell sx={{ color: 'RGB(255,255,255)', borderColor: 'with', fontWeight: 'bold', width: '20%' }}>{t("tle_total_discount")}</TableCell>
|
||||
<TableCell sx={{ color: 'RGB(255,255,255)', borderColor: 'with', fontWeight: 'bold', width: '20%' }}>{t("tle_total")}</TableCell>
|
||||
<TableCell sx={{ color: 'RGB(255,255,255)', fontWeight: 'bold', width: '20%' }}>
|
||||
{t("tle_total_unit")}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'RGB(255,255,255)', fontWeight: 'bold', width: '20%' }}>
|
||||
{t("tle_total_taxes")}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'RGB(255,255,255)', fontWeight: 'bold', width: '20%' }}>
|
||||
{t("tle_total_discount")}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'RGB(255,255,255)', fontWeight: 'bold', width: '20%' }}>
|
||||
{t("tle_total")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell sx={{ color: 'RGB(255,255,255)', borderColor: 'with' }}>{totalAmount}</TableCell>
|
||||
<TableCell sx={{ color: 'RGB(255,255,255)', borderColor: 'with' }}>${totalUnitValue.toFixed(2)}</TableCell>
|
||||
<TableCell sx={{ color: 'RGB(255,255,255)', borderColor: 'with' }}>${totalTax.toFixed(2)}</TableCell>
|
||||
<TableCell sx={{ color: 'RGB(255,255,255)', borderColor: 'with' }}>${totalDiscount.toFixed(2)}</TableCell>
|
||||
<TableCell sx={{ color: 'RGB(255,255,255)', borderColor: 'with' }}>${grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell sx={{ color: 'RGB(255,255,255)' }}>
|
||||
${products?.totalSale?.toFixed(2) || '0.00'}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'RGB(255,255,255)' }}>
|
||||
${products?.totalTaxes?.toFixed(2) || '0.00'}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'RGB(255,255,255)' }}>
|
||||
$0.00 {/* Descuento total si aplica */}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'RGB(255,255,255)' }}>
|
||||
${((products?.totalWithPromotion ?? 0) + (products?.totalTaxes ?? 0)).toFixed(2)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
|
@ -89,4 +97,4 @@ const ProductTable: React.FC<ProductTableProps> = ({ products }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default ProductTable;
|
||||
export default ProductTable;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue