winebox 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- winebox/__init__.py +3 -0
- winebox/cli/__init__.py +1 -0
- winebox/cli/server.py +313 -0
- winebox/cli/user_admin.py +258 -0
- winebox/config.py +43 -0
- winebox/database.py +47 -0
- winebox/main.py +78 -0
- winebox/models/__init__.py +8 -0
- winebox/models/inventory.py +46 -0
- winebox/models/transaction.py +64 -0
- winebox/models/user.py +55 -0
- winebox/models/wine.py +66 -0
- winebox/routers/__init__.py +5 -0
- winebox/routers/auth.py +90 -0
- winebox/routers/cellar.py +102 -0
- winebox/routers/search.py +127 -0
- winebox/routers/transactions.py +63 -0
- winebox/routers/wines.py +287 -0
- winebox/schemas/__init__.py +13 -0
- winebox/schemas/transaction.py +40 -0
- winebox/schemas/wine.py +79 -0
- winebox/services/__init__.py +7 -0
- winebox/services/auth.py +123 -0
- winebox/services/image_storage.py +90 -0
- winebox/services/ocr.py +128 -0
- winebox/services/wine_parser.py +411 -0
- winebox/static/css/style.css +1086 -0
- winebox/static/index.html +271 -0
- winebox/static/js/app.js +703 -0
- winebox-0.1.0.dist-info/METADATA +283 -0
- winebox-0.1.0.dist-info/RECORD +34 -0
- winebox-0.1.0.dist-info/WHEEL +4 -0
- winebox-0.1.0.dist-info/entry_points.txt +3 -0
- winebox-0.1.0.dist-info/licenses/LICENSE +21 -0
winebox/static/js/app.js
ADDED
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WineBox - Wine Cellar Management Application
|
|
3
|
+
* Frontend JavaScript
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const API_BASE = '/api';
|
|
7
|
+
|
|
8
|
+
// State
|
|
9
|
+
let currentPage = 'dashboard';
|
|
10
|
+
let authToken = localStorage.getItem('winebox_token');
|
|
11
|
+
let currentUser = null;
|
|
12
|
+
|
|
13
|
+
// Initialize app
|
|
14
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
15
|
+
initNavigation();
|
|
16
|
+
initForms();
|
|
17
|
+
initModals();
|
|
18
|
+
initAuth();
|
|
19
|
+
checkAuth();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Authentication
|
|
23
|
+
function initAuth() {
|
|
24
|
+
// Login form
|
|
25
|
+
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
|
26
|
+
|
|
27
|
+
// Logout button
|
|
28
|
+
document.getElementById('logout-btn').addEventListener('click', handleLogout);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function checkAuth() {
|
|
32
|
+
if (!authToken) {
|
|
33
|
+
showLoginPage();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const response = await fetchWithAuth(`${API_BASE}/auth/me`);
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
throw new Error('Not authenticated');
|
|
41
|
+
}
|
|
42
|
+
currentUser = await response.json();
|
|
43
|
+
showMainApp();
|
|
44
|
+
} catch (error) {
|
|
45
|
+
localStorage.removeItem('winebox_token');
|
|
46
|
+
authToken = null;
|
|
47
|
+
showLoginPage();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function showLoginPage() {
|
|
52
|
+
document.body.classList.add('logged-out');
|
|
53
|
+
document.getElementById('page-login').classList.add('active');
|
|
54
|
+
document.getElementById('user-info').style.display = 'none';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function showMainApp() {
|
|
58
|
+
document.body.classList.remove('logged-out');
|
|
59
|
+
document.getElementById('page-login').classList.remove('active');
|
|
60
|
+
document.getElementById('user-info').style.display = 'flex';
|
|
61
|
+
document.getElementById('username-display').textContent = currentUser.username;
|
|
62
|
+
loadDashboard();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function handleLogin(e) {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
const form = e.target;
|
|
68
|
+
const username = document.getElementById('login-username').value;
|
|
69
|
+
const password = document.getElementById('login-password').value;
|
|
70
|
+
const errorDiv = document.getElementById('login-error');
|
|
71
|
+
|
|
72
|
+
errorDiv.style.display = 'none';
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const formData = new URLSearchParams();
|
|
76
|
+
formData.append('username', username);
|
|
77
|
+
formData.append('password', password);
|
|
78
|
+
|
|
79
|
+
const response = await fetch(`${API_BASE}/auth/token`, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: {
|
|
82
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
83
|
+
},
|
|
84
|
+
body: formData
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
const error = await response.json();
|
|
89
|
+
throw new Error(error.detail || 'Login failed');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const data = await response.json();
|
|
93
|
+
authToken = data.access_token;
|
|
94
|
+
localStorage.setItem('winebox_token', authToken);
|
|
95
|
+
|
|
96
|
+
form.reset();
|
|
97
|
+
checkAuth();
|
|
98
|
+
} catch (error) {
|
|
99
|
+
errorDiv.textContent = error.message;
|
|
100
|
+
errorDiv.style.display = 'block';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function handleLogout() {
|
|
105
|
+
localStorage.removeItem('winebox_token');
|
|
106
|
+
authToken = null;
|
|
107
|
+
currentUser = null;
|
|
108
|
+
showLoginPage();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Fetch with authentication
|
|
112
|
+
async function fetchWithAuth(url, options = {}) {
|
|
113
|
+
const headers = options.headers || {};
|
|
114
|
+
if (authToken) {
|
|
115
|
+
headers['Authorization'] = `Bearer ${authToken}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const response = await fetch(url, { ...options, headers });
|
|
119
|
+
|
|
120
|
+
// Handle 401 - redirect to login
|
|
121
|
+
if (response.status === 401) {
|
|
122
|
+
localStorage.removeItem('winebox_token');
|
|
123
|
+
authToken = null;
|
|
124
|
+
showLoginPage();
|
|
125
|
+
throw new Error('Session expired');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return response;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Navigation
|
|
132
|
+
function initNavigation() {
|
|
133
|
+
document.querySelectorAll('.nav-link').forEach(link => {
|
|
134
|
+
link.addEventListener('click', (e) => {
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
const page = link.dataset.page;
|
|
137
|
+
navigateTo(page);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function navigateTo(page) {
|
|
143
|
+
// Update nav links
|
|
144
|
+
document.querySelectorAll('.nav-link').forEach(link => {
|
|
145
|
+
link.classList.toggle('active', link.dataset.page === page);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Update pages
|
|
149
|
+
document.querySelectorAll('.page').forEach(p => {
|
|
150
|
+
p.classList.toggle('active', p.id === `page-${page}`);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
currentPage = page;
|
|
154
|
+
|
|
155
|
+
// Load page data
|
|
156
|
+
switch (page) {
|
|
157
|
+
case 'dashboard':
|
|
158
|
+
loadDashboard();
|
|
159
|
+
break;
|
|
160
|
+
case 'cellar':
|
|
161
|
+
loadCellar();
|
|
162
|
+
break;
|
|
163
|
+
case 'history':
|
|
164
|
+
loadHistory();
|
|
165
|
+
break;
|
|
166
|
+
case 'search':
|
|
167
|
+
// Search results loaded on form submit
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Forms
|
|
173
|
+
function initForms() {
|
|
174
|
+
// Check-in form
|
|
175
|
+
const checkinForm = document.getElementById('checkin-form');
|
|
176
|
+
checkinForm.addEventListener('submit', handleCheckin);
|
|
177
|
+
checkinForm.addEventListener('reset', () => {
|
|
178
|
+
document.getElementById('front-preview').innerHTML = 'Tap to take photo or select image';
|
|
179
|
+
document.getElementById('back-preview').innerHTML = 'Tap to take photo or select image';
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Image previews - make clickable to trigger file input
|
|
183
|
+
const frontLabel = document.getElementById('front-label');
|
|
184
|
+
const backLabel = document.getElementById('back-label');
|
|
185
|
+
const frontPreview = document.getElementById('front-preview');
|
|
186
|
+
const backPreview = document.getElementById('back-preview');
|
|
187
|
+
|
|
188
|
+
frontLabel.addEventListener('change', (e) => {
|
|
189
|
+
previewImage(e.target, 'front-preview');
|
|
190
|
+
});
|
|
191
|
+
backLabel.addEventListener('change', (e) => {
|
|
192
|
+
previewImage(e.target, 'back-preview');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Click on preview to trigger file input
|
|
196
|
+
frontPreview.addEventListener('click', () => {
|
|
197
|
+
frontLabel.click();
|
|
198
|
+
});
|
|
199
|
+
backPreview.addEventListener('click', () => {
|
|
200
|
+
backLabel.click();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Search form
|
|
204
|
+
document.getElementById('search-form').addEventListener('submit', handleSearch);
|
|
205
|
+
|
|
206
|
+
// Checkout form
|
|
207
|
+
document.getElementById('checkout-form').addEventListener('submit', handleCheckout);
|
|
208
|
+
|
|
209
|
+
// Cellar filter
|
|
210
|
+
document.getElementById('cellar-filter').addEventListener('change', loadCellar);
|
|
211
|
+
document.getElementById('cellar-search').addEventListener('input', debounce(loadCellar, 300));
|
|
212
|
+
|
|
213
|
+
// History filter
|
|
214
|
+
document.getElementById('history-filter').addEventListener('change', loadHistory);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function previewImage(input, previewId) {
|
|
218
|
+
const preview = document.getElementById(previewId);
|
|
219
|
+
if (input.files && input.files[0]) {
|
|
220
|
+
const reader = new FileReader();
|
|
221
|
+
reader.onload = (e) => {
|
|
222
|
+
preview.innerHTML = `<img src="${e.target.result}" alt="Preview">`;
|
|
223
|
+
};
|
|
224
|
+
reader.readAsDataURL(input.files[0]);
|
|
225
|
+
} else {
|
|
226
|
+
preview.innerHTML = '';
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function handleCheckin(e) {
|
|
231
|
+
e.preventDefault();
|
|
232
|
+
const form = e.target;
|
|
233
|
+
const formData = new FormData(form);
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const response = await fetchWithAuth(`${API_BASE}/wines/checkin`, {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
body: formData
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
if (!response.ok) {
|
|
242
|
+
const error = await response.json();
|
|
243
|
+
throw new Error(error.detail || 'Check-in failed');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const wine = await response.json();
|
|
247
|
+
showToast(`Successfully checked in: ${wine.name}`, 'success');
|
|
248
|
+
form.reset();
|
|
249
|
+
navigateTo('cellar');
|
|
250
|
+
} catch (error) {
|
|
251
|
+
showToast(error.message, 'error');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function handleSearch(e) {
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
const form = e.target;
|
|
258
|
+
const params = new URLSearchParams();
|
|
259
|
+
|
|
260
|
+
// Add non-empty form values to params
|
|
261
|
+
const formData = new FormData(form);
|
|
262
|
+
for (const [key, value] of formData) {
|
|
263
|
+
if (value && key !== 'in_stock') {
|
|
264
|
+
params.append(key, value);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Handle checkbox
|
|
269
|
+
const inStock = document.getElementById('search-in-stock');
|
|
270
|
+
if (inStock.checked) {
|
|
271
|
+
params.append('in_stock', 'true');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
const response = await fetchWithAuth(`${API_BASE}/search?${params}`);
|
|
276
|
+
const wines = await response.json();
|
|
277
|
+
renderWineGrid('search-results', wines);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
showToast('Search failed', 'error');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function handleCheckout(e) {
|
|
284
|
+
e.preventDefault();
|
|
285
|
+
const wineId = document.getElementById('checkout-wine-id').value;
|
|
286
|
+
const formData = new FormData(e.target);
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const response = await fetchWithAuth(`${API_BASE}/wines/${wineId}/checkout`, {
|
|
290
|
+
method: 'POST',
|
|
291
|
+
body: formData
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
if (!response.ok) {
|
|
295
|
+
const error = await response.json();
|
|
296
|
+
throw new Error(error.detail || 'Check-out failed');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const wine = await response.json();
|
|
300
|
+
showToast(`Successfully checked out: ${wine.name}`, 'success');
|
|
301
|
+
closeModals();
|
|
302
|
+
loadCellar();
|
|
303
|
+
loadDashboard();
|
|
304
|
+
} catch (error) {
|
|
305
|
+
showToast(error.message, 'error');
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Modals
|
|
310
|
+
function initModals() {
|
|
311
|
+
// Close buttons
|
|
312
|
+
document.querySelectorAll('.modal-close, .modal-cancel').forEach(btn => {
|
|
313
|
+
btn.addEventListener('click', closeModals);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// Click outside to close
|
|
317
|
+
document.querySelectorAll('.modal').forEach(modal => {
|
|
318
|
+
modal.addEventListener('click', (e) => {
|
|
319
|
+
if (e.target === modal) {
|
|
320
|
+
closeModals();
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Escape key to close
|
|
326
|
+
document.addEventListener('keydown', (e) => {
|
|
327
|
+
if (e.key === 'Escape') {
|
|
328
|
+
closeModals();
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function closeModals() {
|
|
334
|
+
document.querySelectorAll('.modal').forEach(modal => {
|
|
335
|
+
modal.classList.remove('active');
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function openModal(modalId) {
|
|
340
|
+
document.getElementById(modalId).classList.add('active');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Dashboard
|
|
344
|
+
async function loadDashboard() {
|
|
345
|
+
try {
|
|
346
|
+
// Load summary
|
|
347
|
+
const summaryResponse = await fetchWithAuth(`${API_BASE}/cellar/summary`);
|
|
348
|
+
const summary = await summaryResponse.json();
|
|
349
|
+
|
|
350
|
+
document.getElementById('stat-total-bottles').textContent = summary.total_bottles;
|
|
351
|
+
document.getElementById('stat-unique-wines').textContent = summary.unique_wines;
|
|
352
|
+
document.getElementById('stat-total-tracked').textContent = summary.total_wines_tracked;
|
|
353
|
+
|
|
354
|
+
// Render breakdowns
|
|
355
|
+
renderChartList('by-country', summary.by_country);
|
|
356
|
+
renderChartList('by-grape', summary.by_grape_variety);
|
|
357
|
+
renderChartList('by-vintage', summary.by_vintage);
|
|
358
|
+
|
|
359
|
+
// Load recent transactions
|
|
360
|
+
const transResponse = await fetchWithAuth(`${API_BASE}/transactions?limit=10`);
|
|
361
|
+
const transactions = await transResponse.json();
|
|
362
|
+
renderActivityList(transactions);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
console.error('Failed to load dashboard:', error);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function renderChartList(containerId, data) {
|
|
369
|
+
const container = document.getElementById(containerId);
|
|
370
|
+
if (!data || Object.keys(data).length === 0) {
|
|
371
|
+
container.innerHTML = '<div class="empty-state">No data yet</div>';
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
container.innerHTML = Object.entries(data)
|
|
376
|
+
.sort((a, b) => b[1] - a[1])
|
|
377
|
+
.slice(0, 5)
|
|
378
|
+
.map(([label, value]) => `
|
|
379
|
+
<div class="chart-item">
|
|
380
|
+
<span class="label">${label}</span>
|
|
381
|
+
<span class="value">${value}</span>
|
|
382
|
+
</div>
|
|
383
|
+
`).join('');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function renderActivityList(transactions) {
|
|
387
|
+
const container = document.getElementById('recent-activity');
|
|
388
|
+
if (!transactions || transactions.length === 0) {
|
|
389
|
+
container.innerHTML = '<div class="empty-state">No recent activity</div>';
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
container.innerHTML = transactions.map(t => `
|
|
394
|
+
<div class="activity-item">
|
|
395
|
+
<div class="activity-icon ${t.transaction_type === 'CHECK_IN' ? 'check-in' : 'check-out'}">
|
|
396
|
+
${t.transaction_type === 'CHECK_IN' ? '+' : '-'}
|
|
397
|
+
</div>
|
|
398
|
+
<div class="activity-content">
|
|
399
|
+
<div class="activity-title">
|
|
400
|
+
${t.wine ? t.wine.name : 'Unknown Wine'}
|
|
401
|
+
${t.wine && t.wine.vintage ? `(${t.wine.vintage})` : ''}
|
|
402
|
+
</div>
|
|
403
|
+
<div class="activity-meta">
|
|
404
|
+
${t.quantity} bottle${t.quantity > 1 ? 's' : ''} ·
|
|
405
|
+
${formatDate(t.transaction_date)}
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
`).join('');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Cellar
|
|
413
|
+
async function loadCellar() {
|
|
414
|
+
const filter = document.getElementById('cellar-filter').value;
|
|
415
|
+
const search = document.getElementById('cellar-search').value;
|
|
416
|
+
|
|
417
|
+
let url = `${API_BASE}/wines?`;
|
|
418
|
+
if (filter === 'in-stock') {
|
|
419
|
+
url += 'in_stock=true&';
|
|
420
|
+
} else if (filter === 'out-of-stock') {
|
|
421
|
+
url += 'in_stock=false&';
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
const response = await fetchWithAuth(url);
|
|
426
|
+
let wines = await response.json();
|
|
427
|
+
|
|
428
|
+
// Client-side search filter
|
|
429
|
+
if (search) {
|
|
430
|
+
const searchLower = search.toLowerCase();
|
|
431
|
+
wines = wines.filter(w =>
|
|
432
|
+
w.name.toLowerCase().includes(searchLower) ||
|
|
433
|
+
(w.winery && w.winery.toLowerCase().includes(searchLower)) ||
|
|
434
|
+
(w.grape_variety && w.grape_variety.toLowerCase().includes(searchLower))
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
renderWineGrid('cellar-list', wines);
|
|
439
|
+
} catch (error) {
|
|
440
|
+
console.error('Failed to load cellar:', error);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function renderWineGrid(containerId, wines) {
|
|
445
|
+
const container = document.getElementById(containerId);
|
|
446
|
+
if (!wines || wines.length === 0) {
|
|
447
|
+
container.innerHTML = '<div class="empty-state"><h3>No wines found</h3><p>Try adjusting your filters</p></div>';
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
container.innerHTML = wines.map(wine => {
|
|
452
|
+
const quantity = wine.inventory ? wine.inventory.quantity : 0;
|
|
453
|
+
const inStock = quantity > 0;
|
|
454
|
+
|
|
455
|
+
return `
|
|
456
|
+
<div class="wine-card" data-wine-id="${wine.id}">
|
|
457
|
+
<div class="wine-card-image">
|
|
458
|
+
${wine.front_label_image_path
|
|
459
|
+
? `<img src="/api/images/${wine.front_label_image_path}" alt="${wine.name}">`
|
|
460
|
+
: '<span style="color: white; opacity: 0.6;">No Image</span>'
|
|
461
|
+
}
|
|
462
|
+
</div>
|
|
463
|
+
<div class="wine-card-content">
|
|
464
|
+
<div class="wine-card-title">${wine.name}</div>
|
|
465
|
+
<div class="wine-card-subtitle">
|
|
466
|
+
${wine.winery ? wine.winery : ''}
|
|
467
|
+
${wine.vintage ? ` - ${wine.vintage}` : ''}
|
|
468
|
+
</div>
|
|
469
|
+
<div class="wine-card-details">
|
|
470
|
+
${wine.grape_variety ? `<span class="wine-tag">${wine.grape_variety}</span>` : ''}
|
|
471
|
+
${wine.region ? `<span class="wine-tag">${wine.region}</span>` : ''}
|
|
472
|
+
${wine.country ? `<span class="wine-tag">${wine.country}</span>` : ''}
|
|
473
|
+
</div>
|
|
474
|
+
<div class="wine-card-footer">
|
|
475
|
+
<span class="wine-quantity ${inStock ? '' : 'out-of-stock'}">
|
|
476
|
+
${inStock ? `${quantity} bottle${quantity > 1 ? 's' : ''}` : 'Out of stock'}
|
|
477
|
+
</span>
|
|
478
|
+
${inStock ? `<button class="btn btn-small btn-primary checkout-btn" data-wine-id="${wine.id}" data-quantity="${quantity}">Check Out</button>` : ''}
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
</div>
|
|
482
|
+
`;
|
|
483
|
+
}).join('');
|
|
484
|
+
|
|
485
|
+
// Add click handlers
|
|
486
|
+
container.querySelectorAll('.wine-card').forEach(card => {
|
|
487
|
+
card.addEventListener('click', (e) => {
|
|
488
|
+
if (!e.target.classList.contains('checkout-btn')) {
|
|
489
|
+
showWineDetail(card.dataset.wineId);
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
container.querySelectorAll('.checkout-btn').forEach(btn => {
|
|
495
|
+
btn.addEventListener('click', (e) => {
|
|
496
|
+
e.stopPropagation();
|
|
497
|
+
openCheckoutModal(btn.dataset.wineId, btn.dataset.quantity);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function showWineDetail(wineId) {
|
|
503
|
+
try {
|
|
504
|
+
const response = await fetchWithAuth(`${API_BASE}/wines/${wineId}`);
|
|
505
|
+
const wine = await response.json();
|
|
506
|
+
|
|
507
|
+
const quantity = wine.inventory ? wine.inventory.quantity : 0;
|
|
508
|
+
|
|
509
|
+
document.getElementById('wine-detail').innerHTML = `
|
|
510
|
+
<div class="wine-detail-images">
|
|
511
|
+
${wine.front_label_image_path
|
|
512
|
+
? `<div class="wine-detail-image"><img src="/api/images/${wine.front_label_image_path}" alt="Front label"></div>`
|
|
513
|
+
: ''
|
|
514
|
+
}
|
|
515
|
+
${wine.back_label_image_path
|
|
516
|
+
? `<div class="wine-detail-image"><img src="/api/images/${wine.back_label_image_path}" alt="Back label"></div>`
|
|
517
|
+
: ''
|
|
518
|
+
}
|
|
519
|
+
</div>
|
|
520
|
+
<div class="wine-detail-info">
|
|
521
|
+
<h3>${wine.name}</h3>
|
|
522
|
+
<div class="wine-detail-meta">
|
|
523
|
+
${wine.winery ? wine.winery : ''}
|
|
524
|
+
${wine.vintage ? ` - ${wine.vintage}` : ''}
|
|
525
|
+
</div>
|
|
526
|
+
|
|
527
|
+
<div class="wine-detail-field">
|
|
528
|
+
<div class="label">In Stock</div>
|
|
529
|
+
<div class="value">${quantity} bottle${quantity !== 1 ? 's' : ''}</div>
|
|
530
|
+
</div>
|
|
531
|
+
|
|
532
|
+
${wine.grape_variety ? `
|
|
533
|
+
<div class="wine-detail-field">
|
|
534
|
+
<div class="label">Grape Variety</div>
|
|
535
|
+
<div class="value">${wine.grape_variety}</div>
|
|
536
|
+
</div>
|
|
537
|
+
` : ''}
|
|
538
|
+
|
|
539
|
+
${wine.region ? `
|
|
540
|
+
<div class="wine-detail-field">
|
|
541
|
+
<div class="label">Region</div>
|
|
542
|
+
<div class="value">${wine.region}</div>
|
|
543
|
+
</div>
|
|
544
|
+
` : ''}
|
|
545
|
+
|
|
546
|
+
${wine.country ? `
|
|
547
|
+
<div class="wine-detail-field">
|
|
548
|
+
<div class="label">Country</div>
|
|
549
|
+
<div class="value">${wine.country}</div>
|
|
550
|
+
</div>
|
|
551
|
+
` : ''}
|
|
552
|
+
|
|
553
|
+
${wine.alcohol_percentage ? `
|
|
554
|
+
<div class="wine-detail-field">
|
|
555
|
+
<div class="label">Alcohol</div>
|
|
556
|
+
<div class="value">${wine.alcohol_percentage}%</div>
|
|
557
|
+
</div>
|
|
558
|
+
` : ''}
|
|
559
|
+
|
|
560
|
+
${wine.front_label_text ? `
|
|
561
|
+
<div class="wine-detail-field">
|
|
562
|
+
<div class="label">OCR Text (Front Label)</div>
|
|
563
|
+
<div class="wine-detail-ocr">${wine.front_label_text}</div>
|
|
564
|
+
</div>
|
|
565
|
+
` : ''}
|
|
566
|
+
|
|
567
|
+
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
|
|
568
|
+
${quantity > 0 ? `<button class="btn btn-primary" onclick="openCheckoutModal('${wine.id}', ${quantity})">Check Out</button>` : ''}
|
|
569
|
+
<button class="btn btn-danger" onclick="deleteWine('${wine.id}')">Delete Wine</button>
|
|
570
|
+
</div>
|
|
571
|
+
</div>
|
|
572
|
+
|
|
573
|
+
${wine.transactions && wine.transactions.length > 0 ? `
|
|
574
|
+
<div class="wine-detail-transactions">
|
|
575
|
+
<h3>Transaction History</h3>
|
|
576
|
+
<div class="transaction-list">
|
|
577
|
+
${wine.transactions.map(t => `
|
|
578
|
+
<div class="transaction-item">
|
|
579
|
+
<span class="transaction-type ${t.transaction_type === 'CHECK_IN' ? 'check-in' : 'check-out'}">
|
|
580
|
+
${t.transaction_type === 'CHECK_IN' ? 'In' : 'Out'}
|
|
581
|
+
</span>
|
|
582
|
+
<span class="transaction-quantity">${t.quantity} bottle${t.quantity > 1 ? 's' : ''}</span>
|
|
583
|
+
<span class="transaction-date">${formatDate(t.transaction_date)}</span>
|
|
584
|
+
${t.notes ? `<span>${t.notes}</span>` : ''}
|
|
585
|
+
</div>
|
|
586
|
+
`).join('')}
|
|
587
|
+
</div>
|
|
588
|
+
</div>
|
|
589
|
+
` : ''}
|
|
590
|
+
`;
|
|
591
|
+
|
|
592
|
+
openModal('wine-modal');
|
|
593
|
+
} catch (error) {
|
|
594
|
+
showToast('Failed to load wine details', 'error');
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function openCheckoutModal(wineId, availableQuantity) {
|
|
599
|
+
document.getElementById('checkout-wine-id').value = wineId;
|
|
600
|
+
document.getElementById('checkout-quantity').max = availableQuantity;
|
|
601
|
+
document.getElementById('checkout-quantity').value = 1;
|
|
602
|
+
document.getElementById('checkout-available').textContent = `(${availableQuantity} available)`;
|
|
603
|
+
document.getElementById('checkout-notes').value = '';
|
|
604
|
+
openModal('checkout-modal');
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function deleteWine(wineId) {
|
|
608
|
+
if (!confirm('Are you sure you want to delete this wine and all its history?')) {
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
try {
|
|
613
|
+
const response = await fetchWithAuth(`${API_BASE}/wines/${wineId}`, {
|
|
614
|
+
method: 'DELETE'
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
if (!response.ok) {
|
|
618
|
+
throw new Error('Delete failed');
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
showToast('Wine deleted', 'success');
|
|
622
|
+
closeModals();
|
|
623
|
+
loadCellar();
|
|
624
|
+
loadDashboard();
|
|
625
|
+
} catch (error) {
|
|
626
|
+
showToast('Failed to delete wine', 'error');
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// History
|
|
631
|
+
async function loadHistory() {
|
|
632
|
+
const filter = document.getElementById('history-filter').value;
|
|
633
|
+
let url = `${API_BASE}/transactions?limit=100`;
|
|
634
|
+
if (filter !== 'all') {
|
|
635
|
+
url += `&transaction_type=${filter}`;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
try {
|
|
639
|
+
const response = await fetchWithAuth(url);
|
|
640
|
+
const transactions = await response.json();
|
|
641
|
+
renderTransactionList(transactions);
|
|
642
|
+
} catch (error) {
|
|
643
|
+
console.error('Failed to load history:', error);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function renderTransactionList(transactions) {
|
|
648
|
+
const container = document.getElementById('history-list');
|
|
649
|
+
if (!transactions || transactions.length === 0) {
|
|
650
|
+
container.innerHTML = '<div class="empty-state"><h3>No transactions yet</h3><p>Check in some wine to get started</p></div>';
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
container.innerHTML = transactions.map(t => `
|
|
655
|
+
<div class="transaction-item">
|
|
656
|
+
<span class="transaction-type ${t.transaction_type === 'CHECK_IN' ? 'check-in' : 'check-out'}">
|
|
657
|
+
${t.transaction_type === 'CHECK_IN' ? 'In' : 'Out'}
|
|
658
|
+
</span>
|
|
659
|
+
<span class="transaction-wine">
|
|
660
|
+
${t.wine ? t.wine.name : 'Unknown Wine'}
|
|
661
|
+
${t.wine && t.wine.vintage ? `<span class="vintage">(${t.wine.vintage})</span>` : ''}
|
|
662
|
+
</span>
|
|
663
|
+
<span class="transaction-quantity">${t.quantity} bottle${t.quantity > 1 ? 's' : ''}</span>
|
|
664
|
+
<span class="transaction-date">${formatDate(t.transaction_date)}</span>
|
|
665
|
+
</div>
|
|
666
|
+
`).join('');
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Utilities
|
|
670
|
+
function formatDate(dateString) {
|
|
671
|
+
const date = new Date(dateString);
|
|
672
|
+
return date.toLocaleDateString('en-US', {
|
|
673
|
+
year: 'numeric',
|
|
674
|
+
month: 'short',
|
|
675
|
+
day: 'numeric',
|
|
676
|
+
hour: '2-digit',
|
|
677
|
+
minute: '2-digit'
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function showToast(message, type = 'info') {
|
|
682
|
+
const container = document.getElementById('toast-container');
|
|
683
|
+
const toast = document.createElement('div');
|
|
684
|
+
toast.className = `toast ${type}`;
|
|
685
|
+
toast.textContent = message;
|
|
686
|
+
container.appendChild(toast);
|
|
687
|
+
|
|
688
|
+
setTimeout(() => {
|
|
689
|
+
toast.remove();
|
|
690
|
+
}, 5000);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function debounce(func, wait) {
|
|
694
|
+
let timeout;
|
|
695
|
+
return function executedFunction(...args) {
|
|
696
|
+
const later = () => {
|
|
697
|
+
clearTimeout(timeout);
|
|
698
|
+
func(...args);
|
|
699
|
+
};
|
|
700
|
+
clearTimeout(timeout);
|
|
701
|
+
timeout = setTimeout(later, wait);
|
|
702
|
+
};
|
|
703
|
+
}
|