608 lines
26 KiB
JavaScript
608 lines
26 KiB
JavaScript
/**
|
|
* Scanner Component
|
|
*
|
|
* The main scanning interface for the stocktake.
|
|
* Features:
|
|
* - QR code scanning via camera
|
|
* - Manual article search
|
|
* - Quantity input with custom keypad
|
|
* - Recent scans list
|
|
*/
|
|
|
|
import { api } from '/mobile/shared/auth.js';
|
|
|
|
export default {
|
|
name: 'Scanner',
|
|
emits: ['close', 'toast'],
|
|
props: {
|
|
stocktake: {
|
|
type: Object,
|
|
required: true
|
|
},
|
|
user: {
|
|
type: Object,
|
|
required: true
|
|
}
|
|
},
|
|
|
|
setup(props, { emit }) {
|
|
const { ref, onMounted, onUnmounted, nextTick, computed } = Vue;
|
|
|
|
// ==================== STATE ====================
|
|
const currentTab = ref('scan'); // 'scan', 'search', 'history'
|
|
const isLoading = ref(false);
|
|
|
|
// Scanner state
|
|
const scanner = ref(null);
|
|
const isScannerActive = ref(false);
|
|
const scannerError = ref('');
|
|
|
|
// Article state
|
|
const scannedArticle = ref(null);
|
|
const quantity = ref('1');
|
|
const rack = ref('');
|
|
const shelf = ref('');
|
|
|
|
// Search state
|
|
const searchQuery = ref('');
|
|
const searchResults = ref([]);
|
|
const categories = ref([]);
|
|
const selectedCategory = ref(0);
|
|
const isSearching = ref(false);
|
|
|
|
// History state
|
|
const recentScans = ref([]);
|
|
const isLoadingHistory = ref(false);
|
|
|
|
// Already scanned warning
|
|
const alreadyScannedWarning = ref(null);
|
|
|
|
// Custom keypad
|
|
const showKeypad = ref(false);
|
|
|
|
// ==================== COMPUTED ====================
|
|
const canSubmit = computed(() => {
|
|
return scannedArticle.value &&
|
|
parseFloat(quantity.value) > 0 &&
|
|
!isLoading.value;
|
|
});
|
|
|
|
// ==================== SCANNER ====================
|
|
const startScanner = async () => {
|
|
scannerError.value = '';
|
|
|
|
try {
|
|
// Initialize scanner
|
|
scanner.value = new Html5Qrcode('qr-reader');
|
|
|
|
await scanner.value.start(
|
|
{ facingMode: 'environment' },
|
|
{
|
|
fps: 10,
|
|
qrbox: { width: 250, height: 250 },
|
|
aspectRatio: 1.0
|
|
},
|
|
onScanSuccess,
|
|
onScanError
|
|
);
|
|
|
|
isScannerActive.value = true;
|
|
} catch (err) {
|
|
console.error('Scanner start error:', err);
|
|
scannerError.value = 'Kamera konnte nicht gestartet werden. Bitte Berechtigungen prüfen.';
|
|
}
|
|
};
|
|
|
|
const stopScanner = async () => {
|
|
if (scanner.value && isScannerActive.value) {
|
|
try {
|
|
await scanner.value.stop();
|
|
} catch (e) {
|
|
console.error('Scanner stop error:', e);
|
|
}
|
|
isScannerActive.value = false;
|
|
}
|
|
};
|
|
|
|
const onScanSuccess = async (decodedText) => {
|
|
// Stop scanner temporarily
|
|
await stopScanner();
|
|
|
|
// Look up article
|
|
await lookupArticle(decodedText);
|
|
};
|
|
|
|
const onScanError = (errorMessage) => {
|
|
// Silent - this fires constantly when no QR code is detected
|
|
};
|
|
|
|
// ==================== ARTICLE LOOKUP ====================
|
|
const lookupArticle = async (code) => {
|
|
isLoading.value = true;
|
|
alreadyScannedWarning.value = null;
|
|
|
|
try {
|
|
const result = await api.get(`WarehouseStocktake/getArticle?code=${encodeURIComponent(code)}`);
|
|
|
|
if (result.success) {
|
|
scannedArticle.value = result.article;
|
|
|
|
// Check if already scanned
|
|
const checkResult = await api.get(
|
|
`WarehouseStocktake/checkAlreadyScanned?stocktakeId=${props.stocktake.id}&articleId=${result.article.id}`
|
|
);
|
|
|
|
if (checkResult.success && checkResult.alreadyScanned) {
|
|
alreadyScannedWarning.value = checkResult.existingItem;
|
|
}
|
|
|
|
// Reset quantity
|
|
quantity.value = '1';
|
|
} else {
|
|
emit('toast', result.message || 'Artikel nicht gefunden', 'error');
|
|
// Restart scanner
|
|
await startScanner();
|
|
}
|
|
} catch (e) {
|
|
emit('toast', 'Fehler beim Laden des Artikels', 'error');
|
|
await startScanner();
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
};
|
|
|
|
// ==================== SUBMIT SCAN ====================
|
|
const submitScan = async (overwrite = false) => {
|
|
if (!canSubmit.value) return;
|
|
|
|
isLoading.value = true;
|
|
|
|
try {
|
|
const payload = {
|
|
stocktakeId: props.stocktake.id,
|
|
articleId: scannedArticle.value.id,
|
|
quantity: parseFloat(quantity.value),
|
|
rack: rack.value || null,
|
|
shelf: shelf.value || null,
|
|
overwrite: overwrite,
|
|
overwriteItemId: overwrite && alreadyScannedWarning.value ? alreadyScannedWarning.value.id : 0
|
|
};
|
|
|
|
const result = await api.post('WarehouseStocktake/submitScan', payload);
|
|
|
|
if (result.success) {
|
|
emit('toast', result.message, 'success');
|
|
|
|
// Reset state
|
|
scannedArticle.value = null;
|
|
quantity.value = '1';
|
|
rack.value = '';
|
|
shelf.value = '';
|
|
alreadyScannedWarning.value = null;
|
|
|
|
// Restart scanner
|
|
await startScanner();
|
|
} else {
|
|
emit('toast', result.message || 'Fehler beim Speichern', 'error');
|
|
}
|
|
} catch (e) {
|
|
emit('toast', 'Netzwerkfehler', 'error');
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
};
|
|
|
|
// ==================== SEARCH ====================
|
|
const loadCategories = async () => {
|
|
const result = await api.get('WarehouseStocktake/getCategories');
|
|
if (result.success) {
|
|
categories.value = result.categories;
|
|
}
|
|
};
|
|
|
|
const searchArticles = async () => {
|
|
if (searchQuery.value.length < 2 && !selectedCategory.value) {
|
|
searchResults.value = [];
|
|
return;
|
|
}
|
|
|
|
isSearching.value = true;
|
|
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (searchQuery.value) params.set('query', searchQuery.value);
|
|
if (selectedCategory.value) params.set('categoryId', selectedCategory.value);
|
|
|
|
const result = await api.get(`WarehouseStocktake/searchArticles?${params}`);
|
|
|
|
if (result.success) {
|
|
searchResults.value = result.articles;
|
|
}
|
|
} catch (e) {
|
|
console.error('Search error:', e);
|
|
} finally {
|
|
isSearching.value = false;
|
|
}
|
|
};
|
|
|
|
const selectSearchResult = async (article) => {
|
|
await stopScanner();
|
|
scannedArticle.value = article;
|
|
quantity.value = '1';
|
|
currentTab.value = 'scan';
|
|
|
|
// Check if already scanned
|
|
const checkResult = await api.get(
|
|
`WarehouseStocktake/checkAlreadyScanned?stocktakeId=${props.stocktake.id}&articleId=${article.id}`
|
|
);
|
|
|
|
if (checkResult.success && checkResult.alreadyScanned) {
|
|
alreadyScannedWarning.value = checkResult.existingItem;
|
|
}
|
|
};
|
|
|
|
// ==================== HISTORY ====================
|
|
const loadHistory = async () => {
|
|
isLoadingHistory.value = true;
|
|
|
|
try {
|
|
const result = await api.get(`WarehouseStocktake/getMyScans?stocktakeId=${props.stocktake.id}`);
|
|
|
|
if (result.success) {
|
|
recentScans.value = result.items;
|
|
}
|
|
} catch (e) {
|
|
console.error('History load error:', e);
|
|
} finally {
|
|
isLoadingHistory.value = false;
|
|
}
|
|
};
|
|
|
|
// ==================== KEYPAD ====================
|
|
const appendDigit = (digit) => {
|
|
if (digit === '.' && quantity.value.includes('.')) return;
|
|
if (quantity.value === '0' && digit !== '.') {
|
|
quantity.value = digit;
|
|
} else {
|
|
quantity.value += digit;
|
|
}
|
|
};
|
|
|
|
const deleteDigit = () => {
|
|
if (quantity.value.length > 1) {
|
|
quantity.value = quantity.value.slice(0, -1);
|
|
} else {
|
|
quantity.value = '0';
|
|
}
|
|
};
|
|
|
|
const clearQuantity = () => {
|
|
quantity.value = '0';
|
|
};
|
|
|
|
// ==================== LIFECYCLE ====================
|
|
const handleClose = async () => {
|
|
await stopScanner();
|
|
emit('close');
|
|
};
|
|
|
|
const switchTab = async (tab) => {
|
|
currentTab.value = tab;
|
|
|
|
if (tab === 'scan' && !scannedArticle.value) {
|
|
await nextTick();
|
|
await startScanner();
|
|
} else if (tab === 'search') {
|
|
await stopScanner();
|
|
await loadCategories();
|
|
} else if (tab === 'history') {
|
|
await stopScanner();
|
|
await loadHistory();
|
|
}
|
|
};
|
|
|
|
const cancelScan = async () => {
|
|
scannedArticle.value = null;
|
|
alreadyScannedWarning.value = null;
|
|
quantity.value = '1';
|
|
await startScanner();
|
|
};
|
|
|
|
onMounted(async () => {
|
|
await startScanner();
|
|
});
|
|
|
|
onUnmounted(async () => {
|
|
await stopScanner();
|
|
});
|
|
|
|
return {
|
|
// State
|
|
currentTab,
|
|
isLoading,
|
|
isScannerActive,
|
|
scannerError,
|
|
scannedArticle,
|
|
quantity,
|
|
rack,
|
|
shelf,
|
|
searchQuery,
|
|
searchResults,
|
|
categories,
|
|
selectedCategory,
|
|
isSearching,
|
|
recentScans,
|
|
isLoadingHistory,
|
|
alreadyScannedWarning,
|
|
showKeypad,
|
|
canSubmit,
|
|
|
|
// Methods
|
|
startScanner,
|
|
stopScanner,
|
|
submitScan,
|
|
searchArticles,
|
|
selectSearchResult,
|
|
loadHistory,
|
|
appendDigit,
|
|
deleteDigit,
|
|
clearQuantity,
|
|
handleClose,
|
|
switchTab,
|
|
cancelScan
|
|
};
|
|
},
|
|
|
|
template: `
|
|
<div class="flex flex-col h-full bg-slate-100 dark:bg-slate-900">
|
|
<!-- Header -->
|
|
<header class="bg-white dark:bg-slate-800 shadow-sm p-4 flex-shrink-0">
|
|
<div class="flex items-center justify-between">
|
|
<button @click="handleClose" class="p-2 -ml-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-600 dark:text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</button>
|
|
<h1 class="text-lg font-bold text-slate-800 dark:text-white truncate px-2">
|
|
{{ stocktake.title || 'Inventur #' + stocktake.stocktakeNumber }}
|
|
</h1>
|
|
<div class="w-10"></div>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="flex mt-4 bg-slate-100 dark:bg-slate-700 rounded-lg p-1">
|
|
<button
|
|
@click="switchTab('scan')"
|
|
:class="[currentTab === 'scan' ? 'bg-white dark:bg-slate-600 shadow' : '']"
|
|
class="flex-1 py-2 text-sm font-medium rounded-md transition text-slate-700 dark:text-slate-200"
|
|
>
|
|
Scannen
|
|
</button>
|
|
<button
|
|
@click="switchTab('search')"
|
|
:class="[currentTab === 'search' ? 'bg-white dark:bg-slate-600 shadow' : '']"
|
|
class="flex-1 py-2 text-sm font-medium rounded-md transition text-slate-700 dark:text-slate-200"
|
|
>
|
|
Suche
|
|
</button>
|
|
<button
|
|
@click="switchTab('history')"
|
|
:class="[currentTab === 'history' ? 'bg-white dark:bg-slate-600 shadow' : '']"
|
|
class="flex-1 py-2 text-sm font-medium rounded-md transition text-slate-700 dark:text-slate-200"
|
|
>
|
|
Verlauf
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Content -->
|
|
<main class="flex-grow overflow-y-auto">
|
|
<!-- SCAN TAB -->
|
|
<div v-if="currentTab === 'scan'" class="p-4 space-y-4">
|
|
<!-- Scanner or Article View -->
|
|
<div v-if="!scannedArticle" class="space-y-4">
|
|
<!-- QR Scanner -->
|
|
<div id="qr-reader" class="w-full rounded-lg overflow-hidden bg-black"></div>
|
|
|
|
<!-- Scanner Error -->
|
|
<div v-if="scannerError" class="p-4 bg-red-50 dark:bg-red-900/30 rounded-lg">
|
|
<p class="text-sm text-red-600 dark:text-red-400">{{ scannerError }}</p>
|
|
<button @click="startScanner" class="mt-2 text-sm font-medium text-primary">
|
|
Erneut versuchen
|
|
</button>
|
|
</div>
|
|
|
|
<p class="text-center text-sm text-slate-500 dark:text-slate-400">
|
|
QR-Code scannen oder Artikel suchen
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Scanned Article -->
|
|
<div v-else class="space-y-4">
|
|
<!-- Already Scanned Warning -->
|
|
<div v-if="alreadyScannedWarning" class="p-4 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg">
|
|
<div class="flex items-start">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-amber-500 mr-2 flex-shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
|
</svg>
|
|
<div>
|
|
<p class="font-medium text-amber-800 dark:text-amber-300">Bereits gescannt</p>
|
|
<p class="text-sm text-amber-700 dark:text-amber-400 mt-1">
|
|
Menge: {{ alreadyScannedWarning.countedQuantity }}<br>
|
|
Von: {{ alreadyScannedWarning.scannedBy }}<br>
|
|
Am: {{ alreadyScannedWarning.scannedAt }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Article Info -->
|
|
<div class="bg-white dark:bg-slate-800 p-4 rounded-lg shadow-sm">
|
|
<h3 class="font-bold text-lg text-slate-800 dark:text-white">
|
|
{{ scannedArticle.title }}
|
|
</h3>
|
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
|
Art.-Nr.: {{ scannedArticle.articleNumber }}
|
|
</p>
|
|
<p v-if="scannedArticle.categoryName" class="text-sm text-slate-500 dark:text-slate-400">
|
|
Kategorie: {{ scannedArticle.categoryName }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Quantity Input -->
|
|
<div class="bg-white dark:bg-slate-800 p-4 rounded-lg shadow-sm">
|
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
|
Menge ({{ scannedArticle.unit || 'Stk.' }})
|
|
</label>
|
|
<div
|
|
@click="showKeypad = true"
|
|
class="w-full p-4 text-3xl font-bold text-center border-2 border-slate-300 dark:border-slate-600 rounded-lg cursor-pointer hover:border-primary transition text-slate-800 dark:text-white"
|
|
>
|
|
{{ quantity }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Optional Fields -->
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Regal</label>
|
|
<input v-model="rack" type="text" class="w-full p-3 border border-slate-300 dark:border-slate-600 rounded-lg dark:bg-slate-800 dark:text-white" placeholder="z.B. A1">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Fach</label>
|
|
<input v-model="shelf" type="text" class="w-full p-3 border border-slate-300 dark:border-slate-600 rounded-lg dark:bg-slate-800 dark:text-white" placeholder="z.B. 3">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Action Buttons -->
|
|
<div class="space-y-2">
|
|
<button
|
|
v-if="alreadyScannedWarning"
|
|
@click="submitScan(false)"
|
|
:disabled="!canSubmit"
|
|
class="w-full py-4 bg-green-600 text-white font-bold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Zur Menge addieren
|
|
</button>
|
|
<button
|
|
v-if="alreadyScannedWarning"
|
|
@click="submitScan(true)"
|
|
:disabled="!canSubmit"
|
|
class="w-full py-4 bg-amber-600 text-white font-bold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Überschreiben
|
|
</button>
|
|
<button
|
|
v-if="!alreadyScannedWarning"
|
|
@click="submitScan(false)"
|
|
:disabled="!canSubmit"
|
|
class="w-full py-4 bg-green-600 text-white font-bold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{{ isLoading ? 'Speichert...' : 'Speichern' }}
|
|
</button>
|
|
<button @click="cancelScan" class="w-full py-3 text-slate-600 dark:text-slate-400 font-medium">
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- SEARCH TAB -->
|
|
<div v-else-if="currentTab === 'search'" class="p-4 space-y-4">
|
|
<div class="sticky top-0 bg-slate-100 dark:bg-slate-900 pb-2 space-y-3">
|
|
<input
|
|
v-model="searchQuery"
|
|
@input="searchArticles"
|
|
type="search"
|
|
placeholder="Artikel suchen..."
|
|
class="w-full p-3 border border-slate-300 dark:border-slate-600 rounded-lg dark:bg-slate-800 dark:text-white"
|
|
>
|
|
<select
|
|
v-model="selectedCategory"
|
|
@change="searchArticles"
|
|
class="w-full p-3 border border-slate-300 dark:border-slate-600 rounded-lg dark:bg-slate-800 dark:text-white"
|
|
>
|
|
<option :value="0">Alle Kategorien</option>
|
|
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div v-if="isSearching" class="text-center py-8">
|
|
<div class="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full mx-auto"></div>
|
|
</div>
|
|
|
|
<div v-else-if="searchResults.length === 0" class="text-center py-8">
|
|
<p class="text-slate-500 dark:text-slate-400">
|
|
{{ searchQuery.length < 2 ? 'Mindestens 2 Zeichen eingeben' : 'Keine Artikel gefunden' }}
|
|
</p>
|
|
</div>
|
|
|
|
<div v-else class="space-y-2">
|
|
<div
|
|
v-for="article in searchResults"
|
|
:key="article.id"
|
|
@click="selectSearchResult(article)"
|
|
class="bg-white dark:bg-slate-800 p-3 rounded-lg shadow-sm cursor-pointer hover:shadow-md active:scale-[0.98] transition"
|
|
>
|
|
<p class="font-medium text-slate-800 dark:text-white">{{ article.title }}</p>
|
|
<p class="text-sm text-slate-500 dark:text-slate-400">{{ article.articleNumber }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- HISTORY TAB -->
|
|
<div v-else-if="currentTab === 'history'" class="p-4">
|
|
<div v-if="isLoadingHistory" class="space-y-3">
|
|
<div v-for="i in 5" :key="i" class="bg-white dark:bg-slate-800 p-3 rounded-lg shadow-sm animate-pulse">
|
|
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded w-3/4 mb-2"></div>
|
|
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded w-1/2"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="recentScans.length === 0" class="text-center py-8">
|
|
<p class="text-slate-500 dark:text-slate-400">Noch keine Scans</p>
|
|
</div>
|
|
|
|
<div v-else class="space-y-2">
|
|
<div v-for="scan in recentScans" :key="scan.id" class="bg-white dark:bg-slate-800 p-3 rounded-lg shadow-sm">
|
|
<div class="flex justify-between items-start">
|
|
<div>
|
|
<p class="font-medium text-slate-800 dark:text-white">{{ scan.articleTitle }}</p>
|
|
<p class="text-sm text-slate-500 dark:text-slate-400">{{ scan.articleNumber }}</p>
|
|
</div>
|
|
<div class="text-right">
|
|
<p class="font-bold text-slate-800 dark:text-white">{{ scan.countedQuantity }} {{ scan.unit }}</p>
|
|
<p class="text-xs text-slate-400">{{ scan.scannedAt }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Custom Keypad Modal -->
|
|
<transition name="slide-up">
|
|
<div v-if="showKeypad" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end">
|
|
<div class="bg-white dark:bg-slate-800 w-full rounded-t-2xl p-4 safe-area-bottom">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<button @click="clearQuantity" class="px-4 py-2 text-red-500 font-medium">C</button>
|
|
<div class="text-2xl font-bold text-slate-800 dark:text-white">{{ quantity }}</div>
|
|
<button @click="showKeypad = false" class="px-4 py-2 text-primary font-medium">Fertig</button>
|
|
</div>
|
|
<div class="grid grid-cols-3 gap-2">
|
|
<button v-for="d in ['1','2','3','4','5','6','7','8','9','.','0']" :key="d" @click="appendDigit(d)" class="py-4 text-xl font-bold bg-slate-100 dark:bg-slate-700 rounded-lg active:bg-slate-200 dark:active:bg-slate-600 text-slate-800 dark:text-white">
|
|
{{ d }}
|
|
</button>
|
|
<button @click="deleteDigit" class="py-4 text-xl font-bold bg-slate-100 dark:bg-slate-700 rounded-lg active:bg-slate-200 dark:active:bg-slate-600 text-slate-800 dark:text-white">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M3 12l6.414 6.414a2 2 0 001.414.586H19a2 2 0 002-2V7a2 2 0 00-2-2h-8.172a2 2 0 00-1.414.586L3 12z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
`
|
|
};
|