diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..74b98f5
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,576 @@
+const notification = document.getElementById('notification');
+const currentDateEl = document.getElementById('current-date');
+const orderDateInput = document.getElementById('order-date');
+const customerNameInput = document.getElementById('customer-name');
+const orderForm = document.getElementById('order-form');
+const ordersContainer = document.getElementById('orders');
+const inventoryList = document.getElementById('inventory');
+const advanceButton = document.getElementById('advance-date');
+
+const API_BASE =
+ window.location.protocol === 'file:' ? 'http://localhost:3000' : window.location.origin;
+
+let products = [];
+let orders = [];
+let currentDate = '';
+
+function getLocalISODate() {
+ const now = new Date();
+ const offsetMs = now.getTimezoneOffset() * 60 * 1000;
+ return new Date(now.getTime() - offsetMs).toISOString().slice(0, 10);
+}
+
+class OrderCard {
+ constructor(order, allOrders, allProducts) {
+ this.order = order;
+ this.orders = allOrders;
+ this.products = allProducts;
+ }
+
+ build() {
+ const card = document.createElement('div');
+ card.className = 'card shadow-sm order-card';
+
+ const body = document.createElement('div');
+ body.className = 'card-body';
+
+ body.appendChild(this.buildHeader());
+ body.appendChild(this.buildItemsSection());
+
+ card.appendChild(body);
+ return card;
+ }
+
+ buildHeader() {
+ const header = document.createElement('div');
+ header.className = 'd-flex justify-content-between flex-wrap gap-2';
+
+ const info = document.createElement('div');
+ const name = document.createElement('h3');
+ name.className = 'h5 mb-1';
+ name.textContent = this.order.customer_name;
+
+ const idMeta = document.createElement('div');
+ idMeta.className = 'text-muted order-meta';
+ idMeta.textContent = `ID: ${this.order.id}`;
+
+ const dateMeta = document.createElement('div');
+ dateMeta.className = 'text-muted order-meta';
+ dateMeta.textContent = `Дата: ${this.order.order_date}`;
+
+ info.appendChild(name);
+ info.appendChild(idMeta);
+ info.appendChild(dateMeta);
+
+ const actions = document.createElement('div');
+ actions.className = 'd-flex gap-2 align-items-start';
+ actions.appendChild(this.buildButton('Изменить', 'btn-outline-secondary', {
+ action: 'edit-order',
+ id: this.order.id
+ }));
+ actions.appendChild(this.buildButton('Удалить', 'btn-outline-danger', {
+ action: 'delete-order',
+ id: this.order.id
+ }));
+
+ header.appendChild(info);
+ header.appendChild(actions);
+
+ return header;
+ }
+
+ buildItemsSection() {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'mt-3';
+
+ wrapper.appendChild(this.buildItemsTable());
+ wrapper.appendChild(this.buildAddItemForm());
+
+ return wrapper;
+ }
+
+ buildItemsTable() {
+ const table = document.createElement('table');
+ table.className = 'table table-sm align-middle';
+
+ table.innerHTML = `
+
+
+
+
+ `;
+
+ const body = document.createElement('tbody');
+ this.order.items.forEach((item) => {
+ body.appendChild(this.buildItemRow(item));
+ });
+
+ table.appendChild(body);
+ return table;
+ }
+
+ buildItemRow(item) {
+ const row = document.createElement('tr');
+
+ const idCell = document.createElement('td');
+ idCell.className = 'text-muted small';
+ idCell.textContent = item.product_id ?? item.id;
+
+ const productCell = document.createElement('td');
+ const productSelect = this.buildProductSelect(item);
+ productSelect.classList.add('form-select-sm');
+ productSelect.dataset.action = 'item-product';
+ productSelect.dataset.itemId = item.id;
+ productSelect.setAttribute('aria-label', 'Товар');
+ productCell.appendChild(productSelect);
+
+ const quantityCell = document.createElement('td');
+ const quantityInput = document.createElement('input');
+ quantityInput.type = 'number';
+ quantityInput.min = '1';
+ quantityInput.className = 'form-control form-control-sm';
+ quantityInput.value = item.quantity;
+ quantityInput.dataset.action = 'item-quantity';
+ quantityInput.dataset.itemId = item.id;
+ quantityInput.setAttribute('aria-label', 'Количество');
+ quantityCell.appendChild(quantityInput);
+
+ const actionsCell = document.createElement('td');
+ actionsCell.className = 'text-end item-actions';
+ actionsCell.appendChild(this.buildItemActionButtons(item));
+ actionsCell.appendChild(this.buildMoveControls(item));
+
+ row.appendChild(idCell);
+ row.appendChild(productCell);
+ row.appendChild(quantityCell);
+ row.appendChild(actionsCell);
+
+ return row;
+ }
+
+ buildItemActionButtons(item) {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'd-flex gap-2 justify-content-end';
+
+ wrapper.appendChild(
+ this.buildButton('Сохранить', 'btn-outline-primary', {
+ action: 'save-item',
+ orderId: this.order.id,
+ itemId: item.id
+ })
+ );
+ wrapper.appendChild(
+ this.buildButton('Удалить', 'btn-outline-danger', {
+ action: 'delete-item',
+ orderId: this.order.id,
+ itemId: item.id
+ })
+ );
+
+ return wrapper;
+ }
+
+ buildMoveControls(item) {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'd-flex gap-2 justify-content-end mt-2';
+
+ const select = document.createElement('select');
+ select.className = 'form-select form-select-sm';
+ select.dataset.action = 'move-target';
+ select.dataset.itemId = item.id;
+ select.setAttribute('aria-label', 'Выбор заказа для перемещения');
+
+ const placeholder = document.createElement('option');
+ placeholder.value = '';
+ placeholder.textContent = 'Переместить в...';
+ select.appendChild(placeholder);
+
+ this.orders
+ .filter((otherOrder) => otherOrder.id !== this.order.id)
+ .forEach((otherOrder) => {
+ const option = document.createElement('option');
+ option.value = otherOrder.id;
+ option.textContent = otherOrder.customer_name;
+ select.appendChild(option);
+ });
+
+ wrapper.appendChild(select);
+ wrapper.appendChild(
+ this.buildButton('Перенести', 'btn-outline-secondary', {
+ action: 'move-item',
+ itemId: item.id
+ })
+ );
+
+ return wrapper;
+ }
+
+ buildAddItemForm() {
+ const form = document.createElement('form');
+ form.className = 'row g-2 align-items-end';
+ form.dataset.action = 'add-item';
+ form.dataset.orderId = this.order.id;
+
+ const productCol = document.createElement('div');
+ productCol.className = 'col-md-6';
+ const productLabel = document.createElement('label');
+ productLabel.className = 'form-label';
+ productLabel.textContent = 'Товар';
+ const productSelect = this.buildProductSelect();
+ const productSelectId = `add-item-product-${this.order.id}`;
+ productSelect.name = 'productId';
+ productSelect.id = productSelectId;
+ productLabel.htmlFor = productSelectId;
+ productCol.appendChild(productLabel);
+ productCol.appendChild(productSelect);
+
+ const quantityCol = document.createElement('div');
+ quantityCol.className = 'col-md-3';
+ const quantityLabel = document.createElement('label');
+ quantityLabel.className = 'form-label';
+ quantityLabel.textContent = 'Кол-во';
+ const quantityInput = document.createElement('input');
+ const quantityInputId = `add-item-quantity-${this.order.id}`;
+ quantityInput.type = 'number';
+ quantityInput.min = '1';
+ quantityInput.className = 'form-control';
+ quantityInput.name = 'quantity';
+ quantityInput.id = quantityInputId;
+ quantityLabel.htmlFor = quantityInputId;
+ quantityInput.required = true;
+ quantityCol.appendChild(quantityLabel);
+ quantityCol.appendChild(quantityInput);
+
+ const actionCol = document.createElement('div');
+ actionCol.className = 'col-md-3';
+ const addButton = document.createElement('button');
+ addButton.type = 'submit';
+ addButton.className = 'btn btn-success w-100';
+ addButton.textContent = 'Добавить позицию';
+ actionCol.appendChild(addButton);
+
+ form.appendChild(productCol);
+ form.appendChild(quantityCol);
+ form.appendChild(actionCol);
+
+ return form;
+ }
+
+ buildProductSelect(selectedId) {
+ const select = document.createElement('select');
+ select.className = 'form-select';
+ this.products.forEach((product) => {
+ const option = document.createElement('option');
+ option.value = product.id;
+ option.textContent = product.name;
+ if (selectedId && product.id === selectedId) {
+ option.selected = true;
+ }
+ select.appendChild(option);
+ });
+ return select;
+ }
+
+ buildButton(label, style, data = {}) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = `btn ${style} btn-sm`;
+ button.textContent = label;
+ Object.entries(data).forEach(([key, value]) => {
+ button.dataset[key] = value;
+ });
+ return button;
+ }
+}
+
+function showNotification(message, type = 'warning') {
+ notification.textContent = message;
+ notification.className = `alert alert-${type}`;
+ notification.classList.remove('d-none');
+
+ setTimeout(() => {
+ notification.classList.add('d-none');
+ }, 4000);
+}
+
+function resolveApiUrl(url) {
+ if (url.startsWith('http://') || url.startsWith('https://')) {
+ return url;
+ }
+ if (url.startsWith('/')) {
+ return `${API_BASE}${url}`;
+ }
+ return `${API_BASE}/${url}`;
+}
+
+async function fetchJson(url, options) {
+ const response = await fetch(resolveApiUrl(url), options);
+ if (!response.ok) {
+ const payload = await response.json().catch(() => ({ message: 'Ошибка запроса.' }));
+ throw new Error(payload.message || 'Ошибка запроса.');
+ }
+ if (response.status === 204) {
+ return null;
+ }
+ return response.json();
+}
+
+function renderInventory() {
+ inventoryList.innerHTML = '';
+ products.forEach((product) => {
+ const item = document.createElement('li');
+ item.className = 'list-group-item d-flex justify-content-between align-items-center';
+ item.innerHTML = `
+ ${product.name}
+ ${product.quantity}
+ `;
+ inventoryList.appendChild(item);
+ });
+}
+
+function renderOrders() {
+ ordersContainer.innerHTML = '';
+ if (orders.length === 0) {
+ ordersContainer.innerHTML = 'ID
+ Товар
+ Количество
+ Действия
+
Заказы отсутствуют.
'; + return; + } + + orders.forEach((order) => { + const card = new OrderCard(order, orders, products).build(); + ordersContainer.appendChild(card); + }); +} + +function normalizeProducts(data) { + if (!Array.isArray(data)) { + return []; + } + return data.map((product) => ({ + id: product.id ?? product.ID, + name: product.name ?? product.pr_name, + quantity: product.quantity ?? product.count + })); +} + +function normalizeOrderItems(items) { + if (!Array.isArray(items)) { + return []; + } + return items.map((item, index) => ({ + id: item.id ?? item.item_id ?? `${item.product_id ?? item.productId ?? 'item'}-${index}`, + product_id: item.product_id ?? item.productId ?? item.product, + product_name: item.product_name ?? item.name, + quantity: item.quantity, + price: item.price + })); +} + +function normalizeOrders(data) { + if (!Array.isArray(data)) { + return []; + } + return data.map((order) => ({ + id: order.id ?? order.ID, + customer_name: order.customer_name ?? order.customer, + order_date: order.order_date ?? order.date, + status: order.status, + total_amount: order.total_amount ?? order.total, + items: normalizeOrderItems(order.items) + })); +} + +async function refreshData() { + const [currentDateData, productsData, ordersData] = await Promise.all([ + fetchJson('/api/current-date'), + fetchJson('/api/products'), + fetchJson('/api/orders') + ]); + currentDate = currentDateData.currentDate; + products = normalizeProducts(productsData); + orders = normalizeOrders(ordersData); + + currentDateEl.textContent = currentDate; + orderDateInput.min = currentDate; + if (!orderDateInput.value) { + orderDateInput.value = currentDate; + } + + renderInventory(); + renderOrders(); +} + +function initializeApp() { + const requiredElements = [ + notification, + currentDateEl, + orderDateInput, + customerNameInput, + orderForm, + ordersContainer, + inventoryList, + advanceButton + ]; + + if (requiredElements.some((element) => !element)) { + console.error('Missing required elements. Проверьте разметку страницы.'); + return; + } + + const initialDate = getLocalISODate(); + currentDate = initialDate; + currentDateEl.textContent = initialDate; + orderDateInput.min = initialDate; + if (!orderDateInput.value) { + orderDateInput.value = initialDate; + } + + orderForm.addEventListener('submit', async (event) => { + event.preventDefault(); + try { + await fetchJson('/api/orders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + customerName: customerNameInput.value.trim(), + orderDate: orderDateInput.value + }) + }); + customerNameInput.value = ''; + orderDateInput.value = currentDate; + await refreshData(); + } catch (error) { + showNotification(error.message, 'warning'); + } + }); + + advanceButton.addEventListener('click', async () => { + try { + await fetchJson('/api/current-date/advance', { method: 'POST' }); + await refreshData(); + showNotification('Дата переведена. Заказы за сегодня отправлены.', 'success'); + } catch (error) { + showNotification(error.message, 'warning'); + } + }); + + ordersContainer.addEventListener('submit', async (event) => { + if (event.target.matches('form[data-action="add-item"]')) { + event.preventDefault(); + const orderId = event.target.dataset.orderId; + const productId = event.target.productId.value; + const quantity = Number.parseInt(event.target.quantity.value, 10); + + try { + const response = await fetchJson(`/api/orders/${orderId}/items`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ productId, quantity }) + }); + const order = orders.find((entry) => entry.id === orderId); + const product = products.find((entry) => entry.id === productId); + if (order) { + const newItem = normalizeOrderItems([ + { + id: response?.itemId, + product_id: productId, + product_name: product?.name, + quantity + } + ])[0]; + order.items = [...order.items, newItem]; + renderOrders(); + } + event.target.reset(); + await refreshData(); + } catch (error) { + showNotification(error.message, 'warning'); + } + } + }); + + ordersContainer.addEventListener('click', async (event) => { + const { action, id, itemId, orderId } = event.target.dataset; + + try { + if (action === 'delete-order') { + await fetchJson(`/api/orders/${id}`, { method: 'DELETE' }); + await refreshData(); + return; + } + + if (action === 'edit-order') { + const newName = prompt('Введите новое ФИО заказчика', ''); + if (newName === null) { + return; + } + const newDate = prompt('Введите дату заказа (YYYY-MM-DD)', currentDate); + if (!newDate) { + return; + } + Date + await fetchJson(`/api/orders/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ customerName: newName, orderDate: newDate }) + }); + await refreshData(); + return; + } + + if (action === 'save-item') { + const quantityInput = ordersContainer.querySelector( + `[data-action="item-quantity"][data-item-id="${itemId}"]` + ); + const productSelect = ordersContainer.querySelector( + `[data-action="item-product"][data-item-id="${itemId}"]` + ); + + await fetchJson(`/api/orders/${orderId}/items/${itemId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + productId: productSelect.value, + quantity: Number.parseInt(quantityInput.value, 10) + }) + }); + await refreshData(); + return; + } + + if (action === 'delete-item') { + await fetchJson(`/api/orders/${orderId}/items/${itemId}`, { method: 'DELETE' }); + await refreshData(); + return; + } + + if (action === 'move-item') { + const targetSelect = ordersContainer.querySelector( + `[data-action="move-target"][data-item-id="${itemId}"]` + ); + if (!targetSelect.value) { + showNotification('Выберите заказ для перемещения.', 'warning'); + return; + } + await fetchJson(`/api/order-items/${itemId}/move`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetOrderId: targetSelect.value }) + }); + await refreshData(); + return; + } + } catch (error) { + showNotification(error.message, 'warning'); + } + }); + + refreshData().catch((error) => { + showNotification(error.message, 'warning'); + }); +} + +initializeApp(); \ No newline at end of file