Xinon mobile/improve
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,7 +3,6 @@ composer.lock
|
|||||||
.project
|
.project
|
||||||
.settings
|
.settings
|
||||||
.idea
|
.idea
|
||||||
.claude/
|
|
||||||
nbproject
|
nbproject
|
||||||
config/config.php
|
config/config.php
|
||||||
scripts/addressdb/import
|
scripts/addressdb/import
|
||||||
@@ -52,3 +51,5 @@ Thumbs.db
|
|||||||
|
|
||||||
/Layout/default/DeviceDetail/
|
/Layout/default/DeviceDetail/
|
||||||
/Layout/default/DeviceDetail/
|
/Layout/default/DeviceDetail/
|
||||||
|
mobile-presentation/
|
||||||
|
nul
|
||||||
|
|||||||
@@ -1,77 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
$appConfig = [
|
||||||
* MobileApp PWA View Template
|
'title' => 'Xinon Mobile',
|
||||||
*
|
'appName' => 'Xinon',
|
||||||
* Main shell for the unified Mobile App.
|
'manifestPath' => '/mobile/manifest.json',
|
||||||
* Vue handles internal navigation between modules.
|
'appJsPath' => '/mobile/app.js',
|
||||||
*/
|
'swPath' => '/mobile/sw.js',
|
||||||
?>
|
];
|
||||||
<!DOCTYPE html>
|
require __DIR__ . '/Base.php';
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
|
||||||
<title>Xinon Mobile</title>
|
|
||||||
<link rel="shortcut icon" href="/assets/images/favicon.ico">
|
|
||||||
|
|
||||||
<!-- PWA Configuration -->
|
|
||||||
<link rel="manifest" href="/mobile/manifest.json">
|
|
||||||
<meta name="theme-color" content="#005384">
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
||||||
<meta name="apple-mobile-web-app-title" content="Xinon">
|
|
||||||
|
|
||||||
<!-- External Libraries (CDN) -->
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/vue@3.4.27/dist/vue.global.prod.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/axios@1.7.2/dist/axios.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
|
||||||
|
|
||||||
<!-- Shared Styles -->
|
|
||||||
<link rel="stylesheet" href="/mobile/shared/base.css">
|
|
||||||
|
|
||||||
<!-- App Configuration -->
|
|
||||||
<script>
|
|
||||||
window.TT_CONFIG = <?= json_encode($JSGlobals ?? []) ?>;
|
|
||||||
|
|
||||||
// Tailwind configuration
|
|
||||||
tailwind.config = {
|
|
||||||
darkMode: 'class',
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
'primary': '#005384',
|
|
||||||
'secondary': '#fac41b',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body class="transition-colors duration-300 overflow-hidden">
|
|
||||||
<div id="app" class="h-screen w-screen overflow-hidden antialiased">
|
|
||||||
<!-- Loading state while Vue initializes -->
|
|
||||||
<div class="flex items-center justify-center h-full bg-slate-100 dark:bg-slate-900">
|
|
||||||
<div class="text-center">
|
|
||||||
<img src="/assets/images/xinon-full-transparent.png" class="h-12 mx-auto mb-4 dark:hidden">
|
|
||||||
<img src="/assets/images/xinon-full-transparent-white.png" class="h-12 mx-auto mb-4 hidden dark:block">
|
|
||||||
<div class="animate-pulse text-slate-500 dark:text-slate-400">Lädt...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Load Vue app as ES module -->
|
|
||||||
<script type="module" src="/mobile/app.js"></script>
|
|
||||||
|
|
||||||
<!-- Register Service Worker -->
|
|
||||||
<script>
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
navigator.serviceWorker.register('/mobile/sw.js')
|
|
||||||
.then(reg => console.log('SW registered:', reg.scope))
|
|
||||||
.catch(err => console.log('SW registration failed:', err));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
67
Layout/default/MobileApp/Base.php
Normal file
67
Layout/default/MobileApp/Base.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
$config = array_merge([
|
||||||
|
'title' => 'Xinon Mobile',
|
||||||
|
'appName' => 'Xinon',
|
||||||
|
'manifestPath' => '/mobile/manifest.json',
|
||||||
|
'appJsPath' => '/mobile/app.js',
|
||||||
|
'swPath' => '/mobile/sw.js',
|
||||||
|
'additionalStylesheets' => [],
|
||||||
|
], $appConfig ?? []);
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
|
<title><?= htmlspecialchars($config['title']) ?></title>
|
||||||
|
<link rel="shortcut icon" href="/assets/images/favicon.ico">
|
||||||
|
<link rel="manifest" href="<?= htmlspecialchars($config['manifestPath']) ?>">
|
||||||
|
<meta name="theme-color" content="#005384">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="<?= htmlspecialchars($config['appName']) ?>">
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/vue@3.4.27/dist/vue.global.prod.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/axios@1.7.2/dist/axios.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
||||||
|
<link rel="stylesheet" href="/mobile/shared/base.css">
|
||||||
|
<?php foreach ($config['additionalStylesheets'] as $sheet): ?>
|
||||||
|
<link rel="stylesheet" href="<?= htmlspecialchars($sheet) ?>">
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<script>
|
||||||
|
window.TT_CONFIG = <?= json_encode($JSGlobals ?? []) ?>;
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
'primary': '#005384',
|
||||||
|
'secondary': '#fac41b',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="transition-colors duration-300 overflow-hidden">
|
||||||
|
<div id="app" class="h-screen w-screen overflow-hidden antialiased">
|
||||||
|
<div class="flex items-center justify-center h-full bg-slate-100 dark:bg-slate-900">
|
||||||
|
<div class="text-center">
|
||||||
|
<img src="/assets/images/xinon-full-transparent.png" class="h-12 mx-auto mb-4 dark:hidden">
|
||||||
|
<img src="/assets/images/xinon-full-transparent-white.png" class="h-12 mx-auto mb-4 hidden dark:block">
|
||||||
|
<div class="animate-pulse text-slate-500 dark:text-slate-400">Lädt...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="<?= htmlspecialchars($config['appJsPath']) ?>"></script>
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('<?= htmlspecialchars($config['swPath']) ?>')
|
||||||
|
.then(reg => console.log('SW registered:', reg.scope))
|
||||||
|
.catch(err => console.log('SW registration failed:', err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,78 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
$appConfig = [
|
||||||
* Warehouse Stocktake PWA View Template
|
'title' => 'Lager Inventur',
|
||||||
*
|
'appName' => 'Inventur',
|
||||||
* This is the HTML shell for the Warehouse Stocktake PWA.
|
'manifestPath' => '/mobile/warehouse-stocktake/manifest.json',
|
||||||
* The Vue application is loaded via ES modules.
|
'appJsPath' => '/mobile/warehouse-stocktake/app.js',
|
||||||
*/
|
'swPath' => '/mobile/warehouse-stocktake/sw.js',
|
||||||
?>
|
'additionalStylesheets' => ['/mobile/warehouse-stocktake/app.css'],
|
||||||
<!DOCTYPE html>
|
];
|
||||||
<html lang="de">
|
require __DIR__ . '/Base.php';
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
|
||||||
<title>Lager Inventur</title>
|
|
||||||
<link rel="shortcut icon" href="/assets/images/favicon.ico">
|
|
||||||
|
|
||||||
<!-- PWA Configuration -->
|
|
||||||
<link rel="manifest" href="/mobile/warehouse-stocktake/manifest.json">
|
|
||||||
<meta name="theme-color" content="#005384">
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
||||||
<meta name="apple-mobile-web-app-title" content="Inventur">
|
|
||||||
|
|
||||||
<!-- External Libraries (CDN) -->
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/vue@3.4.27/dist/vue.global.prod.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/axios@1.7.2/dist/axios.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
|
||||||
|
|
||||||
<!-- Shared Styles -->
|
|
||||||
<link rel="stylesheet" href="/mobile/shared/base.css">
|
|
||||||
<link rel="stylesheet" href="/mobile/warehouse-stocktake/app.css">
|
|
||||||
|
|
||||||
<!-- App Configuration -->
|
|
||||||
<script>
|
|
||||||
window.TT_CONFIG = <?= json_encode($JSGlobals ?? []) ?>;
|
|
||||||
|
|
||||||
// Tailwind configuration
|
|
||||||
tailwind.config = {
|
|
||||||
darkMode: 'class',
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
'primary': '#005384',
|
|
||||||
'secondary': '#fac41b',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body class="transition-colors duration-300 overflow-hidden">
|
|
||||||
<div id="app" class="h-screen w-screen overflow-hidden antialiased">
|
|
||||||
<!-- Loading state while Vue initializes -->
|
|
||||||
<div class="flex items-center justify-center h-full bg-slate-100 dark:bg-slate-900">
|
|
||||||
<div class="text-center">
|
|
||||||
<img src="/assets/images/xinon-full-transparent.png" class="h-12 mx-auto mb-4 dark:hidden">
|
|
||||||
<img src="/assets/images/xinon-full-transparent-white.png" class="h-12 mx-auto mb-4 hidden dark:block">
|
|
||||||
<div class="animate-pulse text-slate-500 dark:text-slate-400">Lädt...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Load Vue app as ES module -->
|
|
||||||
<script type="module" src="/mobile/warehouse-stocktake/app.js"></script>
|
|
||||||
|
|
||||||
<!-- Register Service Worker -->
|
|
||||||
<script>
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
navigator.serviceWorker.register('/mobile/warehouse-stocktake/sw.js')
|
|
||||||
.then(reg => console.log('SW registered:', reg.scope))
|
|
||||||
.catch(err => console.log('SW registration failed:', err));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
|
|
||||||
.invoice-details td {
|
.invoice-details td {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invoice-details td:first-child {
|
.invoice-details td:first-child {
|
||||||
|
|||||||
@@ -1,473 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Warehouse Stocktake Handler
|
|
||||||
*
|
|
||||||
* Handles all endpoints for the Warehouse Stocktake PWA.
|
|
||||||
* Migrated from WarehouseStocktakePWAController with new structure.
|
|
||||||
*/
|
|
||||||
class WarehouseStocktakeHandler extends MobileAppBaseHandler {
|
|
||||||
|
|
||||||
protected $requiredPermission = 'WarehouseUser';
|
|
||||||
protected $appName = 'WarehouseStocktake';
|
|
||||||
protected $viewTemplate = 'MobileApp/WarehouseStocktake';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get active stocktakes that user can participate in
|
|
||||||
* GET /MobileApp/WarehouseStocktake/getActiveStocktakes
|
|
||||||
*/
|
|
||||||
public function getActiveStocktakesAction() {
|
|
||||||
$stocktakes = WarehouseStocktakeModel::getAll(['status' => 'in_progress']);
|
|
||||||
|
|
||||||
$result = [];
|
|
||||||
foreach ($stocktakes as $stocktake) {
|
|
||||||
$location = $stocktake->getLocation();
|
|
||||||
$result[] = [
|
|
||||||
'id' => $stocktake->id,
|
|
||||||
'stocktakeNumber' => $stocktake->stocktakeNumber,
|
|
||||||
'title' => $stocktake->title,
|
|
||||||
'locationName' => $location ? $location->title : 'Unbekannt',
|
|
||||||
'totalScannedItems' => $stocktake->totalScannedItems,
|
|
||||||
'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
self::returnJson(['success' => true, 'stocktakes' => $result]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get stocktake details
|
|
||||||
* GET /MobileApp/WarehouseStocktake/getStocktake?id=X
|
|
||||||
*/
|
|
||||||
public function getStocktakeAction() {
|
|
||||||
$id = intval($this->request->id);
|
|
||||||
if (!$id) {
|
|
||||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$stocktake = WarehouseStocktakeModel::get($id);
|
|
||||||
if (!$stocktake) {
|
|
||||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$location = $stocktake->getLocation();
|
|
||||||
|
|
||||||
self::returnJson([
|
|
||||||
'success' => true,
|
|
||||||
'stocktake' => [
|
|
||||||
'id' => $stocktake->id,
|
|
||||||
'stocktakeNumber' => $stocktake->stocktakeNumber,
|
|
||||||
'title' => $stocktake->title,
|
|
||||||
'status' => $stocktake->status,
|
|
||||||
'locationId' => $stocktake->warehouseLocationId,
|
|
||||||
'locationName' => $location ? $location->title : 'Unbekannt',
|
|
||||||
'totalScannedItems' => $stocktake->totalScannedItems,
|
|
||||||
'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get article by QR code or article number
|
|
||||||
* GET /MobileApp/WarehouseStocktake/getArticle?code=X
|
|
||||||
*/
|
|
||||||
public function getArticleAction() {
|
|
||||||
$code = $this->request->code;
|
|
||||||
|
|
||||||
if (!$code) {
|
|
||||||
self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$articleId = null;
|
|
||||||
|
|
||||||
// Try to parse QR code format: WA:articleId:articleNumber (Warehouse Article)
|
|
||||||
// Also accept WH: for backwards compatibility
|
|
||||||
if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) {
|
|
||||||
$articleId = intval($matches[1]);
|
|
||||||
} else {
|
|
||||||
// Try to find by article number
|
|
||||||
$article = WarehouseArticleModel::getFirst(['articleNumber' => $code]);
|
|
||||||
if ($article) {
|
|
||||||
$articleId = $article->id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$articleId) {
|
|
||||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$article = WarehouseArticleModel::get($articleId);
|
|
||||||
if (!$article) {
|
|
||||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get category name
|
|
||||||
$category = WarehouseCategory::get($article->category_id);
|
|
||||||
|
|
||||||
self::returnJson([
|
|
||||||
'success' => true,
|
|
||||||
'article' => [
|
|
||||||
'id' => $article->id,
|
|
||||||
'articleNumber' => $article->articleNumber,
|
|
||||||
'title' => $article->title,
|
|
||||||
'description' => $article->description ?? '',
|
|
||||||
'unit' => $article->unit ?? 'Stk.',
|
|
||||||
'categoryName' => $category ? $category->name : '',
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search articles by text with optional category filter
|
|
||||||
* GET /MobileApp/WarehouseStocktake/searchArticles?query=X&categoryId=Y
|
|
||||||
*/
|
|
||||||
public function searchArticlesAction() {
|
|
||||||
$query = $this->request->query ?? '';
|
|
||||||
$categoryId = intval($this->request->categoryId ?? 0);
|
|
||||||
|
|
||||||
$db = $this->db();
|
|
||||||
$conditions = ["(isEndOfLife IS NULL OR isEndOfLife = 0)"];
|
|
||||||
|
|
||||||
if ($query && strlen($query) >= 2) {
|
|
||||||
$escapedQuery = $db->escape($query);
|
|
||||||
$conditions[] = "(articleNumber LIKE '%{$escapedQuery}%' OR title LIKE '%{$escapedQuery}%' OR description LIKE '%{$escapedQuery}%')";
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($categoryId > 0) {
|
|
||||||
$conditions[] = "category_id = {$categoryId}";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count($conditions) === 1 && !$categoryId) {
|
|
||||||
self::returnJson(['success' => true, 'articles' => []]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$whereClause = implode(' AND ', $conditions);
|
|
||||||
$result = $db->query("SELECT id, articleNumber, title, unit, category_id
|
|
||||||
FROM WarehouseArticle
|
|
||||||
WHERE {$whereClause}
|
|
||||||
ORDER BY title ASC
|
|
||||||
LIMIT 50");
|
|
||||||
|
|
||||||
$articles = [];
|
|
||||||
while ($row = $result->fetch_assoc()) {
|
|
||||||
$articles[] = [
|
|
||||||
'id' => intval($row['id']),
|
|
||||||
'articleNumber' => $row['articleNumber'],
|
|
||||||
'title' => $row['title'],
|
|
||||||
'unit' => $row['unit'] ?? 'Stk.',
|
|
||||||
'categoryId' => intval($row['category_id'] ?? 0),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
self::returnJson(['success' => true, 'articles' => $articles]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all categories for browsing
|
|
||||||
* GET /MobileApp/WarehouseStocktake/getCategories
|
|
||||||
*/
|
|
||||||
public function getCategoriesAction() {
|
|
||||||
$db = $this->db();
|
|
||||||
$res = $db->query("SELECT id, name FROM WarehouseCategory ORDER BY name ASC");
|
|
||||||
|
|
||||||
$categories = [];
|
|
||||||
while ($row = $res->fetch_assoc()) {
|
|
||||||
$categories[] = [
|
|
||||||
'id' => intval($row['id']),
|
|
||||||
'name' => $row['name'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
self::returnJson(['success' => true, 'categories' => $categories]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if article is already scanned in stocktake
|
|
||||||
* GET /MobileApp/WarehouseStocktake/checkAlreadyScanned?stocktakeId=X&articleId=Y
|
|
||||||
*/
|
|
||||||
public function checkAlreadyScannedAction() {
|
|
||||||
$stocktakeId = intval($this->request->stocktakeId);
|
|
||||||
$articleId = intval($this->request->articleId);
|
|
||||||
|
|
||||||
if (!$stocktakeId || !$articleId) {
|
|
||||||
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$existing = WarehouseStocktakeItemModel::getFirst([
|
|
||||||
'stocktakeId' => $stocktakeId,
|
|
||||||
'articleId' => $articleId,
|
|
||||||
'overwrittenById' => null
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
$db = $this->db();
|
|
||||||
$scannedByResult = $db->query("SELECT name FROM Worker WHERE id = {$existing->scannedBy}");
|
|
||||||
$scannedByRow = $scannedByResult->fetch_assoc();
|
|
||||||
|
|
||||||
self::returnJson([
|
|
||||||
'success' => true,
|
|
||||||
'alreadyScanned' => true,
|
|
||||||
'existingItem' => [
|
|
||||||
'id' => $existing->id,
|
|
||||||
'countedQuantity' => $existing->countedQuantity,
|
|
||||||
'scannedAt' => $existing->scannedAt ? date('d.m.Y H:i', $existing->scannedAt) : null,
|
|
||||||
'scannedBy' => $scannedByRow ? $scannedByRow['name'] : 'Unbekannt',
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
self::returnJson(['success' => true, 'alreadyScanned' => false]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit a scanned item
|
|
||||||
* POST /MobileApp/WarehouseStocktake/submitScan
|
|
||||||
*/
|
|
||||||
public function submitScanAction() {
|
|
||||||
$postData = $this->getPostData();
|
|
||||||
|
|
||||||
$stocktakeId = intval($postData['stocktakeId'] ?? 0);
|
|
||||||
$articleId = intval($postData['articleId'] ?? 0);
|
|
||||||
$quantity = floatval($postData['quantity'] ?? 0);
|
|
||||||
$rack = $postData['rack'] ?? null;
|
|
||||||
$shelf = $postData['shelf'] ?? null;
|
|
||||||
$note = $postData['note'] ?? null;
|
|
||||||
$overwrite = boolval($postData['overwrite'] ?? false);
|
|
||||||
$overwriteItemId = intval($postData['overwriteItemId'] ?? 0);
|
|
||||||
|
|
||||||
if (!$stocktakeId || !$articleId) {
|
|
||||||
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($quantity <= 0) {
|
|
||||||
self::returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify stocktake exists and is in progress
|
|
||||||
$stocktake = WarehouseStocktakeModel::get($stocktakeId);
|
|
||||||
if (!$stocktake) {
|
|
||||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($stocktake->status !== 'in_progress') {
|
|
||||||
self::returnJson(['success' => false, 'message' => 'Inventur ist nicht aktiv']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify article exists
|
|
||||||
$article = WarehouseArticleModel::get($articleId);
|
|
||||||
if (!$article) {
|
|
||||||
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$db = $this->db();
|
|
||||||
|
|
||||||
// If overwrite mode is enabled, mark existing item as overwritten
|
|
||||||
if ($overwrite && $overwriteItemId) {
|
|
||||||
// Create new entry
|
|
||||||
$db->query("INSERT INTO WarehouseStocktakeItem
|
|
||||||
(stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
|
|
||||||
VALUES ({$stocktakeId}, {$articleId}, {$quantity},
|
|
||||||
" . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
|
|
||||||
" . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
|
|
||||||
" . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
|
|
||||||
" . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")");
|
|
||||||
|
|
||||||
$itemId = $db->insert_id;
|
|
||||||
|
|
||||||
// Mark old item as overwritten by new item
|
|
||||||
$db->query("UPDATE WarehouseStocktakeItem SET overwrittenById = {$itemId} WHERE id = {$overwriteItemId}");
|
|
||||||
|
|
||||||
$finalQuantity = $quantity;
|
|
||||||
|
|
||||||
// Log the overwrite
|
|
||||||
WarehouseStocktakeLogModel::log($stocktakeId, 'overwritten', $itemId, [
|
|
||||||
'articleId' => $articleId,
|
|
||||||
'articleNumber' => $article->articleNumber,
|
|
||||||
'articleTitle' => $article->title,
|
|
||||||
'quantity' => $quantity,
|
|
||||||
'overwrittenItemId' => $overwriteItemId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Update stocktake progress
|
|
||||||
$stocktake->updateProgress();
|
|
||||||
|
|
||||||
self::returnJson([
|
|
||||||
'success' => true,
|
|
||||||
'message' => "'{$article->title}' überschrieben ({$quantity} {$article->unit})",
|
|
||||||
'item' => [
|
|
||||||
'id' => $itemId,
|
|
||||||
'articleId' => $articleId,
|
|
||||||
'articleNumber' => $article->articleNumber,
|
|
||||||
'articleTitle' => $article->title,
|
|
||||||
'countedQuantity' => $finalQuantity,
|
|
||||||
'unit' => $article->unit ?? 'Stk.',
|
|
||||||
'rack' => $rack,
|
|
||||||
'shelf' => $shelf,
|
|
||||||
'isOverwrite' => true,
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this article was already scanned in this stocktake (non-overwritten)
|
|
||||||
$existing = WarehouseStocktakeItemModel::getFirst([
|
|
||||||
'stocktakeId' => $stocktakeId,
|
|
||||||
'articleId' => $articleId,
|
|
||||||
'overwrittenById' => null
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
// Update existing entry - add to quantity
|
|
||||||
$newQuantity = $existing->countedQuantity + $quantity;
|
|
||||||
$db->query("UPDATE WarehouseStocktakeItem SET
|
|
||||||
countedQuantity = {$newQuantity},
|
|
||||||
rack = " . ($rack ? "'{$db->escape($rack)}'" : "rack") . ",
|
|
||||||
shelf = " . ($shelf ? "'{$db->escape($shelf)}'" : "shelf") . ",
|
|
||||||
scannedAt = " . time() . ",
|
|
||||||
scannedBy = {$this->user->id}
|
|
||||||
WHERE id = {$existing->id}");
|
|
||||||
|
|
||||||
$itemId = $existing->id;
|
|
||||||
$finalQuantity = $newQuantity;
|
|
||||||
$isUpdate = true;
|
|
||||||
} else {
|
|
||||||
// Create new entry
|
|
||||||
$db->query("INSERT INTO WarehouseStocktakeItem
|
|
||||||
(stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
|
|
||||||
VALUES ({$stocktakeId}, {$articleId}, {$quantity},
|
|
||||||
" . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
|
|
||||||
" . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
|
|
||||||
" . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
|
|
||||||
" . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")");
|
|
||||||
|
|
||||||
$itemId = $db->insert_id;
|
|
||||||
$finalQuantity = $quantity;
|
|
||||||
$isUpdate = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update stocktake progress
|
|
||||||
$stocktake->updateProgress();
|
|
||||||
|
|
||||||
// Log the scan
|
|
||||||
WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [
|
|
||||||
'articleId' => $articleId,
|
|
||||||
'articleNumber' => $article->articleNumber,
|
|
||||||
'articleTitle' => $article->title,
|
|
||||||
'quantity' => $quantity,
|
|
||||||
'totalQuantity' => $finalQuantity,
|
|
||||||
'rack' => $rack,
|
|
||||||
'shelf' => $shelf,
|
|
||||||
'isUpdate' => $isUpdate,
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::returnJson([
|
|
||||||
'success' => true,
|
|
||||||
'message' => $isUpdate
|
|
||||||
? "Menge für '{$article->title}' erhöht auf {$finalQuantity}"
|
|
||||||
: "'{$article->title}' hinzugefügt ({$quantity} {$article->unit})",
|
|
||||||
'item' => [
|
|
||||||
'id' => $itemId,
|
|
||||||
'articleId' => $articleId,
|
|
||||||
'articleNumber' => $article->articleNumber,
|
|
||||||
'articleTitle' => $article->title,
|
|
||||||
'countedQuantity' => $finalQuantity,
|
|
||||||
'unit' => $article->unit ?? 'Stk.',
|
|
||||||
'rack' => $rack,
|
|
||||||
'shelf' => $shelf,
|
|
||||||
'isUpdate' => $isUpdate,
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get recent scans for current user in a stocktake
|
|
||||||
* GET /MobileApp/WarehouseStocktake/getMyScans?stocktakeId=X
|
|
||||||
*/
|
|
||||||
public function getMyScansAction() {
|
|
||||||
$stocktakeId = intval($this->request->stocktakeId);
|
|
||||||
|
|
||||||
if (!$stocktakeId) {
|
|
||||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$db = $this->db();
|
|
||||||
$result = $db->query("SELECT si.*, wa.articleNumber, wa.title as articleTitle, wa.unit
|
|
||||||
FROM WarehouseStocktakeItem si
|
|
||||||
JOIN WarehouseArticle wa ON wa.id = si.articleId
|
|
||||||
WHERE si.stocktakeId = {$stocktakeId}
|
|
||||||
AND si.scannedBy = {$this->user->id}
|
|
||||||
ORDER BY si.scannedAt DESC
|
|
||||||
LIMIT 50");
|
|
||||||
|
|
||||||
$items = [];
|
|
||||||
while ($row = $result->fetch_assoc()) {
|
|
||||||
$items[] = [
|
|
||||||
'id' => intval($row['id']),
|
|
||||||
'articleId' => intval($row['articleId']),
|
|
||||||
'articleNumber' => $row['articleNumber'],
|
|
||||||
'articleTitle' => $row['articleTitle'],
|
|
||||||
'countedQuantity' => floatval($row['countedQuantity']),
|
|
||||||
'unit' => $row['unit'] ?? 'Stk.',
|
|
||||||
'rack' => $row['rack'],
|
|
||||||
'shelf' => $row['shelf'],
|
|
||||||
'scannedAt' => $row['scannedAt'] ? date('H:i', $row['scannedAt']) : null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
self::returnJson(['success' => true, 'items' => $items]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get progress stats
|
|
||||||
* GET /MobileApp/WarehouseStocktake/getProgress?stocktakeId=X
|
|
||||||
*/
|
|
||||||
public function getProgressAction() {
|
|
||||||
$stocktakeId = intval($this->request->stocktakeId);
|
|
||||||
|
|
||||||
if (!$stocktakeId) {
|
|
||||||
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$stocktake = WarehouseStocktakeModel::get($stocktakeId);
|
|
||||||
if (!$stocktake) {
|
|
||||||
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$db = $this->db();
|
|
||||||
|
|
||||||
// Total scanned items
|
|
||||||
$totalResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId}");
|
|
||||||
$totalRow = $totalResult->fetch_assoc();
|
|
||||||
$totalScanned = intval($totalRow['count']);
|
|
||||||
|
|
||||||
// My scanned items
|
|
||||||
$myResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId} AND scannedBy = {$this->user->id}");
|
|
||||||
$myRow = $myResult->fetch_assoc();
|
|
||||||
$myScanned = intval($myRow['count']);
|
|
||||||
|
|
||||||
self::returnJson([
|
|
||||||
'success' => true,
|
|
||||||
'progress' => [
|
|
||||||
'totalScanned' => $totalScanned,
|
|
||||||
'myScanned' => $myScanned,
|
|
||||||
'status' => $stocktake->status,
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,10 +21,7 @@ class MobileAppController extends mfBaseController {
|
|||||||
protected $user;
|
protected $user;
|
||||||
|
|
||||||
protected function init() {
|
protected function init() {
|
||||||
// We handle auth ourselves
|
|
||||||
$this->needlogin = false;
|
$this->needlogin = false;
|
||||||
|
|
||||||
// Try to load user if session exists
|
|
||||||
$me = mfValuecache::singleton()->get("me");
|
$me = mfValuecache::singleton()->get("me");
|
||||||
if (!$me) {
|
if (!$me) {
|
||||||
if (mfLoginController::isLoggedIn()) {
|
if (mfLoginController::isLoggedIn()) {
|
||||||
|
|||||||
@@ -2,20 +2,10 @@
|
|||||||
|
|
||||||
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
|
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
|
||||||
|
|
||||||
/**
|
|
||||||
* Inventur (Stocktake) Handler
|
|
||||||
*
|
|
||||||
* Handles all endpoints for the Lager > Inventur module.
|
|
||||||
* API Base: /MobileApp/Lager/Inventur/{action}
|
|
||||||
*/
|
|
||||||
class InventurHandler extends MobileAppBaseHandler {
|
class InventurHandler extends MobileAppBaseHandler {
|
||||||
|
|
||||||
protected $requiredPermission = 'WarehouseUser';
|
protected $requiredPermission = 'WarehouseUser';
|
||||||
|
|
||||||
/**
|
|
||||||
* Get active stocktakes
|
|
||||||
* GET /MobileApp/Lager/Inventur/getActiveStocktakes
|
|
||||||
*/
|
|
||||||
public function getActiveStocktakesAction() {
|
public function getActiveStocktakesAction() {
|
||||||
$stocktakes = WarehouseStocktakeModel::getAll(['status' => 'in_progress']);
|
$stocktakes = WarehouseStocktakeModel::getAll(['status' => 'in_progress']);
|
||||||
|
|
||||||
@@ -35,9 +25,6 @@ class InventurHandler extends MobileAppBaseHandler {
|
|||||||
self::returnJson(['success' => true, 'stocktakes' => $result]);
|
self::returnJson(['success' => true, 'stocktakes' => $result]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get stocktake details
|
|
||||||
*/
|
|
||||||
public function getStocktakeAction() {
|
public function getStocktakeAction() {
|
||||||
$id = intval($this->request->id);
|
$id = intval($this->request->id);
|
||||||
if (!$id) {
|
if (!$id) {
|
||||||
@@ -68,9 +55,6 @@ class InventurHandler extends MobileAppBaseHandler {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get article by QR code or article number
|
|
||||||
*/
|
|
||||||
public function getArticleAction() {
|
public function getArticleAction() {
|
||||||
$code = $this->request->code;
|
$code = $this->request->code;
|
||||||
|
|
||||||
@@ -116,9 +100,6 @@ class InventurHandler extends MobileAppBaseHandler {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Search articles
|
|
||||||
*/
|
|
||||||
public function searchArticlesAction() {
|
public function searchArticlesAction() {
|
||||||
$query = $this->request->query ?? '';
|
$query = $this->request->query ?? '';
|
||||||
$categoryId = intval($this->request->categoryId ?? 0);
|
$categoryId = intval($this->request->categoryId ?? 0);
|
||||||
@@ -161,9 +142,6 @@ class InventurHandler extends MobileAppBaseHandler {
|
|||||||
self::returnJson(['success' => true, 'articles' => $articles]);
|
self::returnJson(['success' => true, 'articles' => $articles]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get categories
|
|
||||||
*/
|
|
||||||
public function getCategoriesAction() {
|
public function getCategoriesAction() {
|
||||||
$db = $this->db();
|
$db = $this->db();
|
||||||
$res = $db->query("SELECT id, name FROM WarehouseCategory ORDER BY name ASC");
|
$res = $db->query("SELECT id, name FROM WarehouseCategory ORDER BY name ASC");
|
||||||
@@ -178,9 +156,6 @@ class InventurHandler extends MobileAppBaseHandler {
|
|||||||
self::returnJson(['success' => true, 'categories' => $categories]);
|
self::returnJson(['success' => true, 'categories' => $categories]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if already scanned
|
|
||||||
*/
|
|
||||||
public function checkAlreadyScannedAction() {
|
public function checkAlreadyScannedAction() {
|
||||||
$stocktakeId = intval($this->request->stocktakeId);
|
$stocktakeId = intval($this->request->stocktakeId);
|
||||||
$articleId = intval($this->request->articleId);
|
$articleId = intval($this->request->articleId);
|
||||||
@@ -216,9 +191,6 @@ class InventurHandler extends MobileAppBaseHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit scan
|
|
||||||
*/
|
|
||||||
public function submitScanAction() {
|
public function submitScanAction() {
|
||||||
$postData = $this->getPostData();
|
$postData = $this->getPostData();
|
||||||
|
|
||||||
@@ -366,9 +338,6 @@ class InventurHandler extends MobileAppBaseHandler {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get my scans
|
|
||||||
*/
|
|
||||||
public function getMyScansAction() {
|
public function getMyScansAction() {
|
||||||
$stocktakeId = intval($this->request->stocktakeId);
|
$stocktakeId = intval($this->request->stocktakeId);
|
||||||
|
|
||||||
@@ -404,9 +373,6 @@ class InventurHandler extends MobileAppBaseHandler {
|
|||||||
self::returnJson(['success' => true, 'items' => $items]);
|
self::returnJson(['success' => true, 'items' => $items]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get progress
|
|
||||||
*/
|
|
||||||
public function getProgressAction() {
|
public function getProgressAction() {
|
||||||
$stocktakeId = intval($this->request->stocktakeId);
|
$stocktakeId = intval($this->request->stocktakeId);
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,10 @@
|
|||||||
|
|
||||||
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
|
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
|
||||||
|
|
||||||
/**
|
|
||||||
* Movement (Stock Movement) Handler
|
|
||||||
*
|
|
||||||
* Handles all endpoints for the Lager > Movement module.
|
|
||||||
* API Base: /MobileApp/Lager/Movement/{action}
|
|
||||||
*/
|
|
||||||
class MovementHandler extends MobileAppBaseHandler {
|
class MovementHandler extends MobileAppBaseHandler {
|
||||||
|
|
||||||
protected $requiredPermission = 'WarehouseUser';
|
protected $requiredPermission = 'WarehouseUser';
|
||||||
|
|
||||||
/**
|
|
||||||
* Get available locations (Office + Außenlager only)
|
|
||||||
* GET /MobileApp/Lager/Movement/getLocations
|
|
||||||
*/
|
|
||||||
public function getLocationsAction() {
|
public function getLocationsAction() {
|
||||||
$allLocations = WarehouseLocationModel::getAll();
|
$allLocations = WarehouseLocationModel::getAll();
|
||||||
$locations = [];
|
$locations = [];
|
||||||
@@ -33,10 +23,6 @@ class MovementHandler extends MobileAppBaseHandler {
|
|||||||
self::returnJson(['success' => true, 'locations' => $locations]);
|
self::returnJson(['success' => true, 'locations' => $locations]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get article by QR code or article number
|
|
||||||
* GET /MobileApp/Lager/Movement/getArticle?code=X
|
|
||||||
*/
|
|
||||||
public function getArticleAction() {
|
public function getArticleAction() {
|
||||||
$code = $this->request->code;
|
$code = $this->request->code;
|
||||||
|
|
||||||
@@ -84,10 +70,6 @@ class MovementHandler extends MobileAppBaseHandler {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Search articles
|
|
||||||
* GET /MobileApp/Lager/Movement/searchArticles?query=X
|
|
||||||
*/
|
|
||||||
public function searchArticlesAction() {
|
public function searchArticlesAction() {
|
||||||
$query = $this->request->query ?? '';
|
$query = $this->request->query ?? '';
|
||||||
|
|
||||||
@@ -122,10 +104,6 @@ class MovementHandler extends MobileAppBaseHandler {
|
|||||||
self::returnJson(['success' => true, 'articles' => $articles]);
|
self::returnJson(['success' => true, 'articles' => $articles]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get reason categories for a movement type
|
|
||||||
* GET /MobileApp/Lager/Movement/getReasonCategories?type=IN|OUT|ADJUSTMENT
|
|
||||||
*/
|
|
||||||
public function getReasonCategoriesAction() {
|
public function getReasonCategoriesAction() {
|
||||||
$type = $this->request->type ?? null;
|
$type = $this->request->type ?? null;
|
||||||
|
|
||||||
@@ -142,10 +120,6 @@ class MovementHandler extends MobileAppBaseHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current stock for an article at a location
|
|
||||||
* GET /MobileApp/Lager/Movement/getCurrentStock?articleId=X&locationId=X
|
|
||||||
*/
|
|
||||||
public function getCurrentStockAction() {
|
public function getCurrentStockAction() {
|
||||||
$articleId = intval($this->request->articleId ?? 0);
|
$articleId = intval($this->request->articleId ?? 0);
|
||||||
$locationId = intval($this->request->locationId ?? 0);
|
$locationId = intval($this->request->locationId ?? 0);
|
||||||
@@ -165,10 +139,6 @@ class MovementHandler extends MobileAppBaseHandler {
|
|||||||
self::returnJson(['success' => true, 'currentStock' => $currentStock]);
|
self::returnJson(['success' => true, 'currentStock' => $currentStock]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit a stock movement
|
|
||||||
* POST /MobileApp/Lager/Movement/submitMovement
|
|
||||||
*/
|
|
||||||
public function submitMovementAction() {
|
public function submitMovementAction() {
|
||||||
$postData = $this->getPostData();
|
$postData = $this->getPostData();
|
||||||
|
|
||||||
@@ -284,10 +254,6 @@ class MovementHandler extends MobileAppBaseHandler {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get recent movements by current user
|
|
||||||
* GET /MobileApp/Lager/Movement/getMyMovements
|
|
||||||
*/
|
|
||||||
public function getMyMovementsAction() {
|
public function getMyMovementsAction() {
|
||||||
$locationId = intval($this->request->locationId ?? 0);
|
$locationId = intval($this->request->locationId ?? 0);
|
||||||
$limit = intval($this->request->limit ?? 20);
|
$limit = intval($this->request->limit ?? 20);
|
||||||
@@ -330,10 +296,6 @@ class MovementHandler extends MobileAppBaseHandler {
|
|||||||
self::returnJson(['success' => true, 'movements' => $movements]);
|
self::returnJson(['success' => true, 'movements' => $movements]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get movement types with labels
|
|
||||||
* GET /MobileApp/Lager/Movement/getMovementTypes
|
|
||||||
*/
|
|
||||||
public function getMovementTypesAction() {
|
public function getMovementTypesAction() {
|
||||||
$types = [
|
$types = [
|
||||||
['value' => 'IN', 'text' => 'Einbuchung', 'icon' => 'plus-circle', 'color' => 'green'],
|
['value' => 'IN', 'text' => 'Einbuchung', 'icon' => 'plus-circle', 'color' => 'green'],
|
||||||
@@ -343,4 +305,277 @@ class MovementHandler extends MobileAppBaseHandler {
|
|||||||
|
|
||||||
self::returnJson(['success' => true, 'types' => $types]);
|
self::returnJson(['success' => true, 'types' => $types]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getPendingOrdersAction() {
|
||||||
|
$db = $this->db();
|
||||||
|
|
||||||
|
$result = $db->query("SELECT wo.*, wd.name as distributorName
|
||||||
|
FROM WarehouseOrder wo
|
||||||
|
LEFT JOIN WarehouseDistributor wd ON wd.id = wo.distributorId
|
||||||
|
WHERE wo.status IN ('sent', 'partiallyDelivered')
|
||||||
|
ORDER BY wo.`create` DESC");
|
||||||
|
|
||||||
|
$orders = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$positions = json_decode($row['positions'], true) ?: [];
|
||||||
|
$totalItems = array_sum(array_column($positions, 'amount'));
|
||||||
|
|
||||||
|
// Calculate days since sent
|
||||||
|
$daysSinceSent = 0;
|
||||||
|
if (!empty($row['create'])) {
|
||||||
|
$daysSinceSent = floor((time() - intval($row['create'])) / 86400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$orders[] = [
|
||||||
|
'id' => intval($row['id']),
|
||||||
|
'orderNumber' => $row['orderNumber'],
|
||||||
|
'distributorName' => $row['distributorName'] ?? 'Unbekannt',
|
||||||
|
'status' => $row['status'],
|
||||||
|
'statusLabel' => $row['status'] === 'sent' ? 'Versendet' : 'Teilweise geliefert',
|
||||||
|
'totalItems' => $totalItems,
|
||||||
|
'positionCount' => count($positions),
|
||||||
|
'daysSinceSent' => $daysSinceSent,
|
||||||
|
'create' => date('d.m.Y', $row['create']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::returnJson(['success' => true, 'orders' => $orders]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrderForReceivingAction() {
|
||||||
|
$orderId = intval($this->request->orderId ?? 0);
|
||||||
|
|
||||||
|
if ($orderId <= 0) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Keine Bestellung angegeben']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = WarehouseOrderModel::get($orderId);
|
||||||
|
if (!$order) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Bestellung nicht gefunden']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($order->status, ['sent', 'partiallyDelivered'])) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Bestellung ist nicht im Status Versendet oder Teilweise geliefert']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$distributor = WarehouseDistributorModel::get($order->distributorId);
|
||||||
|
$positions = json_decode($order->positions, true) ?: [];
|
||||||
|
|
||||||
|
// Get already delivered quantities from linked movements
|
||||||
|
$linkedMovementIds = $order->linkedMovementIds ? json_decode($order->linkedMovementIds, true) : [];
|
||||||
|
$deliveredByArticle = [];
|
||||||
|
|
||||||
|
foreach ($linkedMovementIds as $movementId) {
|
||||||
|
$movement = WarehouseMovementModel::get($movementId);
|
||||||
|
if ($movement && $movement->movementType === 'IN') {
|
||||||
|
if (!isset($deliveredByArticle[$movement->articleId])) {
|
||||||
|
$deliveredByArticle[$movement->articleId] = 0;
|
||||||
|
}
|
||||||
|
$deliveredByArticle[$movement->articleId] += $movement->quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich positions with article details and delivered quantities
|
||||||
|
$enrichedPositions = [];
|
||||||
|
foreach ($positions as $index => $pos) {
|
||||||
|
$articleId = intval($pos['article']);
|
||||||
|
$article = WarehouseArticleModel::get($articleId);
|
||||||
|
|
||||||
|
$orderedQty = floatval($pos['amount']);
|
||||||
|
$deliveredQty = $deliveredByArticle[$articleId] ?? 0;
|
||||||
|
$remainingQty = max(0, $orderedQty - $deliveredQty);
|
||||||
|
|
||||||
|
$enrichedPositions[] = [
|
||||||
|
'index' => $index,
|
||||||
|
'articleId' => $articleId,
|
||||||
|
'articleNumber' => $article ? $article->articleNumber : '',
|
||||||
|
'articleTitle' => $article ? $article->title : ($pos['article_text'] ?? 'Unbekannt'),
|
||||||
|
'unit' => $article ? ($article->unit ?? 'Stk.') : 'Stk.',
|
||||||
|
'orderedQty' => $orderedQty,
|
||||||
|
'deliveredQty' => $deliveredQty,
|
||||||
|
'remainingQty' => $remainingQty,
|
||||||
|
'receivingQty' => $remainingQty, // Default to remaining
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'order' => [
|
||||||
|
'id' => $order->id,
|
||||||
|
'orderNumber' => $order->orderNumber,
|
||||||
|
'distributorName' => $distributor ? $distributor->name : 'Unbekannt',
|
||||||
|
'status' => $order->status,
|
||||||
|
'note' => $order->note,
|
||||||
|
'create' => date('d.m.Y H:i', $order->create),
|
||||||
|
],
|
||||||
|
'positions' => $enrichedPositions
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submitOrderReceivingAction() {
|
||||||
|
$postData = $this->getPostData();
|
||||||
|
|
||||||
|
$orderId = intval($postData['orderId'] ?? 0);
|
||||||
|
$locationId = intval($postData['locationId'] ?? 0);
|
||||||
|
$positions = $postData['positions'] ?? [];
|
||||||
|
$deliveryNoteFileId = $postData['deliveryNoteFileId'] ?? null;
|
||||||
|
$note = $postData['note'] ?? null;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if ($orderId <= 0) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Keine Bestellung angegeben']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($locationId <= 0) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Kein Lagerort ausgewählt']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($positions)) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Keine Positionen angegeben']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = WarehouseOrderModel::get($orderId);
|
||||||
|
if (!$order) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Bestellung nicht gefunden']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($order->status, ['sent', 'partiallyDelivered'])) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Bestellung ist nicht im Status Versendet oder Teilweise geliefert']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = $this->db();
|
||||||
|
$createdMovementIds = [];
|
||||||
|
$totalReceived = 0;
|
||||||
|
|
||||||
|
// Create movements for each position with quantity > 0
|
||||||
|
foreach ($positions as $pos) {
|
||||||
|
$articleId = intval($pos['articleId'] ?? 0);
|
||||||
|
$quantity = floatval($pos['quantity'] ?? 0);
|
||||||
|
|
||||||
|
if ($articleId <= 0 || $quantity <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create WarehouseItem
|
||||||
|
$existingItems = WarehouseItemModel::getAll([
|
||||||
|
'articleId' => $articleId,
|
||||||
|
'warehouseLocationId' => $locationId
|
||||||
|
]);
|
||||||
|
|
||||||
|
$warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null;
|
||||||
|
$currentQty = $warehouseItem ? floatval($warehouseItem->quantity) : 0;
|
||||||
|
$newQty = $currentQty + $quantity;
|
||||||
|
|
||||||
|
// Update or create WarehouseItem
|
||||||
|
if ($warehouseItem) {
|
||||||
|
$db->query("UPDATE WarehouseItem SET quantity = {$newQty} WHERE id = {$warehouseItem->id}");
|
||||||
|
$warehouseItemId = $warehouseItem->id;
|
||||||
|
} else {
|
||||||
|
$db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, createBy, `create`)
|
||||||
|
VALUES ({$articleId}, {$locationId}, {$newQty}, {$this->user->id}, " . time() . ")");
|
||||||
|
$warehouseItemId = $db->insert_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create movement record
|
||||||
|
$movementNote = "Lagereingang aus Bestellung {$order->orderNumber}";
|
||||||
|
if ($note) {
|
||||||
|
$movementNote .= " - " . $note;
|
||||||
|
}
|
||||||
|
$noteEscaped = "'" . $db->escape($movementNote) . "'";
|
||||||
|
|
||||||
|
$db->query("INSERT INTO WarehouseMovement
|
||||||
|
(movementType, articleId, warehouseLocationId, warehouseItemId, quantity, quantityBefore, quantityAfter, reasonCategory, linkedOrderId, note, userId, createBy, `create`)
|
||||||
|
VALUES ('IN', {$articleId}, {$locationId}, {$warehouseItemId}, {$quantity}, {$currentQty}, {$newQty}, 'Warenlieferung', {$orderId}, {$noteEscaped}, {$this->user->id}, {$this->user->id}, " . time() . ")");
|
||||||
|
|
||||||
|
$movementId = $db->insert_id;
|
||||||
|
|
||||||
|
// Generate movement number
|
||||||
|
$movementNumber = WarehouseMovementModel::generateMovementNumber();
|
||||||
|
$db->query("UPDATE WarehouseMovement SET movementNumber = '{$movementNumber}' WHERE id = {$movementId}");
|
||||||
|
|
||||||
|
$createdMovementIds[] = $movementId;
|
||||||
|
$totalReceived += $quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($createdMovementIds)) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Keine Mengen eingegeben']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update order with linked movement IDs
|
||||||
|
$existingMovementIds = $order->linkedMovementIds ? json_decode($order->linkedMovementIds, true) : [];
|
||||||
|
$allMovementIds = array_merge($existingMovementIds, $createdMovementIds);
|
||||||
|
|
||||||
|
// Update delivery note file IDs if provided
|
||||||
|
$existingFileIds = $order->deliveryNoteFileIds ? json_decode($order->deliveryNoteFileIds, true) : [];
|
||||||
|
if ($deliveryNoteFileId) {
|
||||||
|
$existingFileIds[] = $deliveryNoteFileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine new status - check if all items are now fully delivered
|
||||||
|
$orderPositions = json_decode($order->positions, true) ?: [];
|
||||||
|
$allFullyDelivered = true;
|
||||||
|
|
||||||
|
// Get all delivered quantities including new ones
|
||||||
|
$deliveredByArticle = [];
|
||||||
|
foreach ($allMovementIds as $movementId) {
|
||||||
|
$movement = WarehouseMovementModel::get($movementId);
|
||||||
|
if ($movement && $movement->movementType === 'IN') {
|
||||||
|
if (!isset($deliveredByArticle[$movement->articleId])) {
|
||||||
|
$deliveredByArticle[$movement->articleId] = 0;
|
||||||
|
}
|
||||||
|
$deliveredByArticle[$movement->articleId] += $movement->quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($orderPositions as $pos) {
|
||||||
|
$articleId = intval($pos['article']);
|
||||||
|
$orderedQty = floatval($pos['amount']);
|
||||||
|
$deliveredQty = $deliveredByArticle[$articleId] ?? 0;
|
||||||
|
|
||||||
|
if ($deliveredQty < $orderedQty) {
|
||||||
|
$allFullyDelivered = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$newStatus = $allFullyDelivered ? 'fullyDelivered' : 'partiallyDelivered';
|
||||||
|
|
||||||
|
// Update order
|
||||||
|
$orderAsArray = (array)$order;
|
||||||
|
$orderAsArray['linkedMovementIds'] = json_encode($allMovementIds);
|
||||||
|
$orderAsArray['deliveryNoteFileIds'] = json_encode($existingFileIds);
|
||||||
|
$orderAsArray['status'] = $newStatus;
|
||||||
|
WarehouseOrderModel::update($orderAsArray);
|
||||||
|
|
||||||
|
// Create log entry
|
||||||
|
$logMessage = count($createdMovementIds) . " Lagerbewegung(en) erstellt via Mobile App.";
|
||||||
|
if ($note) {
|
||||||
|
$logMessage .= "\n" . $note;
|
||||||
|
}
|
||||||
|
|
||||||
|
WarehouseLogModel::create([
|
||||||
|
'table' => 'WarehouseOrder',
|
||||||
|
'rowId' => $orderId,
|
||||||
|
'type' => 'statusChange',
|
||||||
|
'message' => "Status geändert auf " . ($newStatus === 'fullyDelivered' ? 'Geliefert' : 'Teilweise geliefert') . ".\n" . $logMessage,
|
||||||
|
'createBy' => $this->user->id,
|
||||||
|
'create' => time()
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'message' => "{$totalReceived} Artikel empfangen. " . count($createdMovementIds) . " Lagerbewegung(en) erstellt.",
|
||||||
|
'newStatus' => $newStatus,
|
||||||
|
'createdMovementIds' => $createdMovementIds
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,761 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShippingNote (Lieferschein) Handler
|
||||||
|
*
|
||||||
|
* Handles all endpoints for the Lager > ShippingNote module.
|
||||||
|
* API Base: /MobileApp/Lager/ShippingNote/{action}
|
||||||
|
*/
|
||||||
|
class ShippingNoteHandler extends MobileAppBaseHandler {
|
||||||
|
|
||||||
|
protected $requiredPermission = 'WarehouseUser';
|
||||||
|
|
||||||
|
// Office coordinates for distance calculation
|
||||||
|
const OFFICE_LAT = 46.99552810791587;
|
||||||
|
const OFFICE_LNG = 15.7751923956463;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get customer by GPS location (nearest within radius)
|
||||||
|
* GET /MobileApp/Lager/ShippingNote/getCustomerByLocation?lat=X&lng=Y
|
||||||
|
*/
|
||||||
|
public function getCustomerByLocationAction() {
|
||||||
|
$lat = floatval($this->request->lat ?? 0);
|
||||||
|
$lng = floatval($this->request->lng ?? 0);
|
||||||
|
$radius = intval($this->request->radius ?? 200); // default 200 meters
|
||||||
|
|
||||||
|
if (!$lat || !$lng) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Koordinaten fehlen']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = $this->db();
|
||||||
|
|
||||||
|
// Haversine formula for distance in meters
|
||||||
|
$sql = "SELECT id, customer_number, company, firstname, lastname, street, zip, city, email, phone, gps_lat, gps_long,
|
||||||
|
(6371000 * acos(
|
||||||
|
cos(radians({$lat})) * cos(radians(gps_lat)) *
|
||||||
|
cos(radians(gps_long) - radians({$lng})) +
|
||||||
|
sin(radians({$lat})) * sin(radians(gps_lat))
|
||||||
|
)) AS distance
|
||||||
|
FROM Address
|
||||||
|
WHERE gps_lat IS NOT NULL
|
||||||
|
AND gps_long IS NOT NULL
|
||||||
|
AND customer_number > 0
|
||||||
|
HAVING distance < {$radius}
|
||||||
|
ORDER BY distance ASC
|
||||||
|
LIMIT 1";
|
||||||
|
|
||||||
|
$result = $db->query($sql);
|
||||||
|
|
||||||
|
if ($result && $row = $result->fetch_assoc()) {
|
||||||
|
// Build display name
|
||||||
|
$displayName = $row['company'] ?: trim($row['firstname'] . ' ' . $row['lastname']);
|
||||||
|
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'found' => true,
|
||||||
|
'customer' => [
|
||||||
|
'id' => intval($row['id']),
|
||||||
|
'customerNumber' => $row['customer_number'],
|
||||||
|
'displayName' => $displayName,
|
||||||
|
'company' => $row['company'],
|
||||||
|
'firstname' => $row['firstname'],
|
||||||
|
'lastname' => $row['lastname'],
|
||||||
|
'street' => $row['street'],
|
||||||
|
'zip' => $row['zip'],
|
||||||
|
'city' => $row['city'],
|
||||||
|
'email' => $row['email'],
|
||||||
|
'phone' => $row['phone'],
|
||||||
|
'distance' => round(floatval($row['distance'])),
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'found' => false,
|
||||||
|
'message' => 'Kein Kunde in der Nähe gefunden'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse geocode coordinates to address
|
||||||
|
* GET /MobileApp/Lager/ShippingNote/reverseGeocode?lat=X&lng=Y
|
||||||
|
*/
|
||||||
|
public function reverseGeocodeAction() {
|
||||||
|
$lat = floatval($this->request->lat ?? 0);
|
||||||
|
$lng = floatval($this->request->lng ?? 0);
|
||||||
|
|
||||||
|
if (!$lat || !$lng) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Koordinaten fehlen']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Google Maps Geocoding API
|
||||||
|
$apiKey = defined('TT_GEOCODING_API_SECRET') ? TT_GEOCODING_API_SECRET : '';
|
||||||
|
if (!$apiKey) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Google Maps API nicht konfiguriert']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = "https://maps.googleapis.com/maps/api/geocode/json?latlng={$lat},{$lng}&key={$apiKey}&language=de";
|
||||||
|
|
||||||
|
$response = @file_get_contents($url);
|
||||||
|
if (!$response) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Geocoding fehlgeschlagen']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
if ($data['status'] !== 'OK' || empty($data['results'])) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Keine Adresse gefunden']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse address components
|
||||||
|
$result = $data['results'][0];
|
||||||
|
$components = $result['address_components'];
|
||||||
|
|
||||||
|
$street = '';
|
||||||
|
$streetNumber = '';
|
||||||
|
$zip = '';
|
||||||
|
$city = '';
|
||||||
|
|
||||||
|
foreach ($components as $comp) {
|
||||||
|
if (in_array('route', $comp['types'])) {
|
||||||
|
$street = $comp['long_name'];
|
||||||
|
}
|
||||||
|
if (in_array('street_number', $comp['types'])) {
|
||||||
|
$streetNumber = $comp['long_name'];
|
||||||
|
}
|
||||||
|
if (in_array('postal_code', $comp['types'])) {
|
||||||
|
$zip = $comp['long_name'];
|
||||||
|
}
|
||||||
|
if (in_array('locality', $comp['types'])) {
|
||||||
|
$city = $comp['long_name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$fullStreet = trim($street . ' ' . $streetNumber);
|
||||||
|
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'address' => [
|
||||||
|
'street' => $fullStreet,
|
||||||
|
'zip' => $zip,
|
||||||
|
'city' => $city,
|
||||||
|
'formatted' => $result['formatted_address'] ?? '',
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search customers by name/company
|
||||||
|
* GET /MobileApp/Lager/ShippingNote/searchCustomers?query=X
|
||||||
|
*/
|
||||||
|
public function searchCustomersAction() {
|
||||||
|
$query = trim($this->request->query ?? '');
|
||||||
|
|
||||||
|
if (strlen($query) < 1) {
|
||||||
|
self::returnJson(['success' => true, 'customers' => []]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = $this->db();
|
||||||
|
|
||||||
|
// Split query into words for lazy search (e.g., "fab her" matches "Fabian Herbst")
|
||||||
|
$words = preg_split('/\s+/', trim($query));
|
||||||
|
$wordConditions = [];
|
||||||
|
|
||||||
|
foreach ($words as $word) {
|
||||||
|
if (strlen($word) < 1) continue;
|
||||||
|
$escapedWord = $db->escape($word);
|
||||||
|
$wordConditions[] = "(company LIKE '%{$escapedWord}%'
|
||||||
|
OR firstname LIKE '%{$escapedWord}%'
|
||||||
|
OR lastname LIKE '%{$escapedWord}%'
|
||||||
|
OR customer_number LIKE '%{$escapedWord}%')";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($wordConditions)) {
|
||||||
|
self::returnJson(['success' => true, 'customers' => []]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereClause = implode(' AND ', $wordConditions);
|
||||||
|
|
||||||
|
$sql = "SELECT id, customer_number, company, firstname, lastname, street, zip, city, email, phone, gps_lat, gps_long
|
||||||
|
FROM Address
|
||||||
|
WHERE customer_number > 0
|
||||||
|
AND ({$whereClause})
|
||||||
|
ORDER BY company, lastname, firstname
|
||||||
|
LIMIT 20";
|
||||||
|
|
||||||
|
$result = $db->query($sql);
|
||||||
|
$customers = [];
|
||||||
|
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$displayName = $row['company'] ?: trim($row['firstname'] . ' ' . $row['lastname']);
|
||||||
|
$customers[] = [
|
||||||
|
'id' => intval($row['id']),
|
||||||
|
'customerNumber' => $row['customer_number'],
|
||||||
|
'displayName' => $displayName,
|
||||||
|
'company' => $row['company'],
|
||||||
|
'firstname' => $row['firstname'],
|
||||||
|
'lastname' => $row['lastname'],
|
||||||
|
'street' => $row['street'],
|
||||||
|
'zip' => $row['zip'],
|
||||||
|
'city' => $row['city'],
|
||||||
|
'email' => $row['email'],
|
||||||
|
'phone' => $row['phone'],
|
||||||
|
'gpsLat' => $row['gps_lat'] ? floatval($row['gps_lat']) : null,
|
||||||
|
'gpsLong' => $row['gps_long'] ? floatval($row['gps_long']) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::returnJson(['success' => true, 'customers' => $customers]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search articles
|
||||||
|
* GET /MobileApp/Lager/ShippingNote/searchArticles?query=X
|
||||||
|
*/
|
||||||
|
public function searchArticlesAction() {
|
||||||
|
$query = trim($this->request->query ?? '');
|
||||||
|
|
||||||
|
if (strlen($query) < 1) {
|
||||||
|
self::returnJson(['success' => true, 'articles' => []]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = $this->db();
|
||||||
|
$escapedQuery = $db->escape($query);
|
||||||
|
|
||||||
|
$sql = "SELECT id, articleNumber, title, unit
|
||||||
|
FROM WarehouseArticle
|
||||||
|
WHERE (isEndOfLife IS NULL OR isEndOfLife = 0)
|
||||||
|
AND (articleNumber LIKE '%{$escapedQuery}%'
|
||||||
|
OR title LIKE '%{$escapedQuery}%')
|
||||||
|
ORDER BY title ASC
|
||||||
|
LIMIT 30";
|
||||||
|
|
||||||
|
$result = $db->query($sql);
|
||||||
|
$articles = [];
|
||||||
|
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$articles[] = [
|
||||||
|
'id' => intval($row['id']),
|
||||||
|
'articleNumber' => $row['articleNumber'],
|
||||||
|
'title' => $row['title'],
|
||||||
|
'unit' => $row['unit'] ?? 'Stk.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::returnJson(['success' => true, 'articles' => $articles]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get article by QR code or article number
|
||||||
|
* GET /MobileApp/Lager/ShippingNote/getArticle?code=X
|
||||||
|
*/
|
||||||
|
public function getArticleAction() {
|
||||||
|
$code = $this->request->code ?? '';
|
||||||
|
|
||||||
|
if (!$code) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$articleId = null;
|
||||||
|
|
||||||
|
// Check for QR code format WA:ID: or WH:ID:
|
||||||
|
if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) {
|
||||||
|
$articleId = intval($matches[1]);
|
||||||
|
} else {
|
||||||
|
// Try to find by article number
|
||||||
|
$article = WarehouseArticleModel::getFirst(['articleNumber' => $code]);
|
||||||
|
if ($article) {
|
||||||
|
$articleId = $article->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$articleId) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$article = WarehouseArticleModel::get($articleId);
|
||||||
|
if (!$article) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'article' => [
|
||||||
|
'id' => $article->id,
|
||||||
|
'articleNumber' => $article->articleNumber,
|
||||||
|
'title' => $article->title,
|
||||||
|
'unit' => $article->unit ?? 'Stk.',
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's assigned car
|
||||||
|
* GET /MobileApp/Lager/ShippingNote/getUserCar?userId=X
|
||||||
|
*/
|
||||||
|
public function getUserCarAction() {
|
||||||
|
$userId = intval($this->request->userId ?? $this->user->id);
|
||||||
|
|
||||||
|
$db = $this->db();
|
||||||
|
|
||||||
|
// Get user's assigned car from TimerecordingCar (user_id is on TimerecordingCar)
|
||||||
|
$sql = "SELECT id, number_plate, brand, model
|
||||||
|
FROM TimerecordingCar
|
||||||
|
WHERE user_id = {$userId}
|
||||||
|
AND (retired IS NULL OR retired = 0)
|
||||||
|
LIMIT 1";
|
||||||
|
|
||||||
|
$result = $db->query($sql);
|
||||||
|
|
||||||
|
if ($result && $row = $result->fetch_assoc()) {
|
||||||
|
$carName = trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? ''));
|
||||||
|
if (!$carName) $carName = $row['number_plate'];
|
||||||
|
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'car' => [
|
||||||
|
'id' => intval($row['id']),
|
||||||
|
'name' => $carName,
|
||||||
|
'plate' => $row['number_plate'],
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'car' => null
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available cars for selection
|
||||||
|
* GET /MobileApp/Lager/ShippingNote/getAllCars
|
||||||
|
*/
|
||||||
|
public function getAllCarsAction() {
|
||||||
|
$db = $this->db();
|
||||||
|
|
||||||
|
$sql = "SELECT id, number_plate, brand, model
|
||||||
|
FROM TimerecordingCar
|
||||||
|
WHERE (retired IS NULL OR retired = 0)
|
||||||
|
ORDER BY brand, model ASC";
|
||||||
|
|
||||||
|
$result = $db->query($sql);
|
||||||
|
$cars = [];
|
||||||
|
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$carName = trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? ''));
|
||||||
|
if (!$carName) $carName = $row['number_plate'];
|
||||||
|
|
||||||
|
$cars[] = [
|
||||||
|
'id' => intval($row['id']),
|
||||||
|
'name' => $carName,
|
||||||
|
'plate' => $row['number_plate'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::returnJson(['success' => true, 'cars' => $cars]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get hour types for selection
|
||||||
|
* GET /MobileApp/Lager/ShippingNote/getHourTypes
|
||||||
|
*/
|
||||||
|
public function getHourTypesAction() {
|
||||||
|
// Hour types matching desktop modal
|
||||||
|
$hourTypes = [
|
||||||
|
['id' => '', 'name' => 'Normal'],
|
||||||
|
['id' => '50', 'name' => '+50%'],
|
||||||
|
['id' => '100', 'name' => '+100%'],
|
||||||
|
['id' => 'regie', 'name' => 'Regie'],
|
||||||
|
];
|
||||||
|
|
||||||
|
self::returnJson(['success' => true, 'hourTypes' => $hourTypes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate round-trip distance from office to coordinates
|
||||||
|
* GET /MobileApp/Lager/ShippingNote/calculateDistance?lat=X&lng=Y
|
||||||
|
*/
|
||||||
|
public function calculateDistanceAction() {
|
||||||
|
$lat = floatval($this->request->lat ?? 0);
|
||||||
|
$lng = floatval($this->request->lng ?? 0);
|
||||||
|
|
||||||
|
if (!$lat || !$lng) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Koordinaten fehlen']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use estimation on localhost for development
|
||||||
|
$isLocalhost = in_array($_SERVER['HTTP_HOST'] ?? '', ['localhost', '127.0.0.1']);
|
||||||
|
|
||||||
|
// Use Google Distance Matrix API for accurate driving distance
|
||||||
|
$apiKey = defined('TT_GEOCODING_API_SECRET') ? TT_GEOCODING_API_SECRET : '';
|
||||||
|
if (!$apiKey || $isLocalhost) {
|
||||||
|
// Fallback to straight-line distance * 1.3 (rough road factor)
|
||||||
|
$distance = $this->haversineDistance(self::OFFICE_LAT, self::OFFICE_LNG, $lat, $lng);
|
||||||
|
$kmOneWay = round($distance / 1000 * 1.3, 1);
|
||||||
|
$kmRoundTrip = $kmOneWay * 2;
|
||||||
|
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'distanceOneWay' => $kmOneWay,
|
||||||
|
'distanceRoundTrip' => $kmRoundTrip,
|
||||||
|
'estimated' => true
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$origin = self::OFFICE_LAT . ',' . self::OFFICE_LNG;
|
||||||
|
$destination = "{$lat},{$lng}";
|
||||||
|
$url = "https://maps.googleapis.com/maps/api/distancematrix/json?origins={$origin}&destinations={$destination}&mode=driving&key={$apiKey}";
|
||||||
|
|
||||||
|
$response = @file_get_contents($url);
|
||||||
|
if (!$response) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Distanzberechnung fehlgeschlagen']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
if ($data['status'] !== 'OK' || empty($data['rows'][0]['elements'][0]['distance'])) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Keine Route gefunden']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$distanceMeters = $data['rows'][0]['elements'][0]['distance']['value'];
|
||||||
|
$kmOneWay = round($distanceMeters / 1000, 1);
|
||||||
|
$kmRoundTrip = $kmOneWay * 2;
|
||||||
|
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'distanceOneWay' => $kmOneWay,
|
||||||
|
'distanceRoundTrip' => $kmRoundTrip,
|
||||||
|
'estimated' => false
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new shipping note
|
||||||
|
* POST /MobileApp/Lager/ShippingNote/create
|
||||||
|
*/
|
||||||
|
public function createAction() {
|
||||||
|
$postData = $this->getPostData();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
$requiredFields = ['deliveryAddressName', 'deliveryAddressLine', 'deliveryAddressPLZ', 'deliveryAddressCity', 'note'];
|
||||||
|
foreach ($requiredFields as $field) {
|
||||||
|
if (empty($postData[$field])) {
|
||||||
|
self::returnJson(['success' => false, 'message' => "Feld '{$field}' ist erforderlich"]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have at least positions OR hoursEntries
|
||||||
|
$positions = $postData['positions'] ?? [];
|
||||||
|
$hoursEntries = $postData['hoursEntries'] ?? [];
|
||||||
|
|
||||||
|
if (empty($positions) && empty($hoursEntries)) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Mindestens eine Position oder Stundenbuchung erforderlich']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = $this->db();
|
||||||
|
|
||||||
|
// Prepare data
|
||||||
|
$data = [
|
||||||
|
'status' => 'new',
|
||||||
|
'type' => null,
|
||||||
|
'billingAddressId' => null,
|
||||||
|
'deliveryAddressName' => $db->escape($postData['deliveryAddressName']),
|
||||||
|
'deliveryAddressLine' => $db->escape($postData['deliveryAddressLine']),
|
||||||
|
'deliveryAddressPLZ' => $db->escape($postData['deliveryAddressPLZ']),
|
||||||
|
'deliveryAddressCity' => $db->escape($postData['deliveryAddressCity']),
|
||||||
|
'deliveryAddressEMail' => $db->escape($postData['deliveryAddressEMail'] ?? ''),
|
||||||
|
'note' => $db->escape($postData['note']),
|
||||||
|
'positions' => json_encode($positions),
|
||||||
|
'hoursEntries' => json_encode($hoursEntries),
|
||||||
|
'textElements' => json_encode($postData['textElements'] ?? []),
|
||||||
|
'metadata' => json_encode($postData['metadata'] ?? []),
|
||||||
|
'create' => time(),
|
||||||
|
'createBy' => $this->user->id,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Generate shipping note number
|
||||||
|
$shippingNoteNumber = WarehouseShippingNoteModel::generateShippingNoteNumber();
|
||||||
|
$data['shippingNoteNumber'] = $shippingNoteNumber;
|
||||||
|
|
||||||
|
// Build INSERT query
|
||||||
|
$columns = implode(', ', array_map(function($k) { return "`{$k}`"; }, array_keys($data)));
|
||||||
|
$values = implode(', ', array_map(function($v) {
|
||||||
|
return $v === null ? 'NULL' : "'{$v}'";
|
||||||
|
}, array_values($data)));
|
||||||
|
|
||||||
|
$db->query("INSERT INTO WarehouseShippingNote ({$columns}) VALUES ({$values})");
|
||||||
|
$id = $db->insert_id;
|
||||||
|
|
||||||
|
if (!$id) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Fehler beim Speichern']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Lieferschein erstellt',
|
||||||
|
'shippingNote' => [
|
||||||
|
'id' => $id,
|
||||||
|
'shippingNoteNumber' => $shippingNoteNumber,
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a shipping note
|
||||||
|
* POST /MobileApp/Lager/ShippingNote/sign?id=X
|
||||||
|
*/
|
||||||
|
public function signAction() {
|
||||||
|
$id = intval($this->request->id ?? 0);
|
||||||
|
$postData = $this->getPostData();
|
||||||
|
|
||||||
|
if (!$id) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Lieferschein-ID fehlt']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$shippingNote = WarehouseShippingNoteModel::get($id);
|
||||||
|
if (!$shippingNote) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Lieferschein nicht gefunden']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already signed
|
||||||
|
if (!empty($shippingNote->signature) || !empty($shippingNote->signatureName)) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Bereits unterschrieben']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate signature data
|
||||||
|
$signature = $postData['signature'] ?? '';
|
||||||
|
$signatureName = $postData['signatureName'] ?? '';
|
||||||
|
|
||||||
|
if (empty($signature) || empty($signatureName)) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Unterschrift und Name erforderlich']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = $this->db();
|
||||||
|
$signatureEscaped = $db->escape($signature);
|
||||||
|
$signatureNameEscaped = $db->escape($signatureName);
|
||||||
|
$signatureDate = date('Y-m-d');
|
||||||
|
|
||||||
|
$db->query("UPDATE WarehouseShippingNote
|
||||||
|
SET signature = '{$signatureEscaped}',
|
||||||
|
signatureName = '{$signatureNameEscaped}',
|
||||||
|
signatureDate = '{$signatureDate}'
|
||||||
|
WHERE id = {$id}");
|
||||||
|
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Unterschrift gespeichert'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get my unsigned shipping notes
|
||||||
|
* GET /MobileApp/Lager/ShippingNote/getMyShippingNotes
|
||||||
|
*/
|
||||||
|
public function getMyShippingNotesAction() {
|
||||||
|
$onlyUnsigned = ($this->request->unsigned ?? '1') === '1';
|
||||||
|
$limit = intval($this->request->limit ?? 20);
|
||||||
|
|
||||||
|
$db = $this->db();
|
||||||
|
|
||||||
|
$whereClause = "createBy = {$this->user->id}";
|
||||||
|
if ($onlyUnsigned) {
|
||||||
|
$whereClause .= " AND (signature IS NULL OR signature = '')";
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "SELECT id, shippingNoteNumber, status, type, deliveryAddressName, deliveryAddressLine,
|
||||||
|
deliveryAddressPLZ, deliveryAddressCity, note,
|
||||||
|
signature, signatureName, signatureDate, `create`
|
||||||
|
FROM WarehouseShippingNote
|
||||||
|
WHERE {$whereClause}
|
||||||
|
ORDER BY `create` DESC
|
||||||
|
LIMIT {$limit}";
|
||||||
|
|
||||||
|
$result = $db->query($sql);
|
||||||
|
$shippingNotes = [];
|
||||||
|
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$shippingNotes[] = [
|
||||||
|
'id' => intval($row['id']),
|
||||||
|
'shippingNoteNumber' => $row['shippingNoteNumber'],
|
||||||
|
'status' => $row['status'],
|
||||||
|
'type' => $row['type'],
|
||||||
|
'deliveryAddressName' => $row['deliveryAddressName'],
|
||||||
|
'deliveryAddressLine' => $row['deliveryAddressLine'],
|
||||||
|
'deliveryAddressPLZ' => $row['deliveryAddressPLZ'],
|
||||||
|
'deliveryAddressCity' => $row['deliveryAddressCity'],
|
||||||
|
'note' => $row['note'],
|
||||||
|
'isSigned' => !empty($row['signature']),
|
||||||
|
'signatureName' => $row['signatureName'],
|
||||||
|
'signatureDate' => $row['signatureDate'],
|
||||||
|
'create' => date('d.m.Y H:i', $row['create']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::returnJson(['success' => true, 'shippingNotes' => $shippingNotes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single shipping note by ID
|
||||||
|
* GET /MobileApp/Lager/ShippingNote/getShippingNote?id=X
|
||||||
|
*/
|
||||||
|
public function getShippingNoteAction() {
|
||||||
|
$id = intval($this->request->id ?? 0);
|
||||||
|
|
||||||
|
if (!$id) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'ID fehlt']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$shippingNote = WarehouseShippingNoteModel::get($id);
|
||||||
|
if (!$shippingNote) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Lieferschein nicht gefunden']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'shippingNote' => [
|
||||||
|
'id' => intval($shippingNote->id),
|
||||||
|
'shippingNoteNumber' => $shippingNote->shippingNoteNumber,
|
||||||
|
'status' => $shippingNote->status,
|
||||||
|
'type' => $shippingNote->type,
|
||||||
|
'billingAddressId' => $shippingNote->billingAddressId,
|
||||||
|
'deliveryAddressName' => $shippingNote->deliveryAddressName,
|
||||||
|
'deliveryAddressLine' => $shippingNote->deliveryAddressLine,
|
||||||
|
'deliveryAddressPLZ' => $shippingNote->deliveryAddressPLZ,
|
||||||
|
'deliveryAddressCity' => $shippingNote->deliveryAddressCity,
|
||||||
|
'deliveryAddressEMail' => $shippingNote->deliveryAddressEMail,
|
||||||
|
'note' => $shippingNote->note,
|
||||||
|
'positions' => json_decode($shippingNote->positions, true) ?? [],
|
||||||
|
'hoursEntries' => json_decode($shippingNote->hoursEntries, true) ?? [],
|
||||||
|
'isSigned' => !empty($shippingNote->signature),
|
||||||
|
'signatureName' => $shippingNote->signatureName,
|
||||||
|
'signatureDate' => $shippingNote->signatureDate,
|
||||||
|
'create' => date('d.m.Y H:i', $shippingNote->create),
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get shipping note types
|
||||||
|
* GET /MobileApp/Lager/ShippingNote/getTypes
|
||||||
|
*/
|
||||||
|
public function getTypesAction() {
|
||||||
|
$types = [
|
||||||
|
['value' => 'V', 'text' => 'Verrechnen'],
|
||||||
|
['value' => 'XI', 'text' => 'Xinon Intern'],
|
||||||
|
['value' => 'XH', 'text' => 'Xinon Hersteller'],
|
||||||
|
['value' => 'SNOPP', 'text' => 'SNOPP'],
|
||||||
|
['value' => 'ESTMK', 'text' => 'Energie Steiermark'],
|
||||||
|
['value' => 'SBIDI', 'text' => 'SBIDI'],
|
||||||
|
];
|
||||||
|
|
||||||
|
self::returnJson(['success' => true, 'types' => $types]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user info (for pre-filling forms)
|
||||||
|
* GET /MobileApp/Lager/ShippingNote/getCurrentUser
|
||||||
|
*/
|
||||||
|
public function getCurrentUserAction() {
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'user' => [
|
||||||
|
'id' => $this->user->id,
|
||||||
|
'name' => $this->user->name,
|
||||||
|
'firstname' => $this->user->firstname ?? '',
|
||||||
|
'lastname' => $this->user->lastname ?? '',
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search employees for multi-employee selection
|
||||||
|
* GET /MobileApp/Lager/ShippingNote/searchEmployees?query=X
|
||||||
|
*/
|
||||||
|
public function searchEmployeesAction() {
|
||||||
|
$query = trim($this->request->query ?? '');
|
||||||
|
|
||||||
|
$db = $this->db();
|
||||||
|
|
||||||
|
// Base query: active workers who have TimerecordingEmployee entry (= employees)
|
||||||
|
$sql = "SELECT w.id, w.name, w.email
|
||||||
|
FROM Worker w
|
||||||
|
INNER JOIN TimerecordingEmployee te ON te.user_id = w.id
|
||||||
|
WHERE w.active = 1";
|
||||||
|
|
||||||
|
// Add search filter if query provided
|
||||||
|
if (strlen($query) >= 1) {
|
||||||
|
// Split query into words for lazy search (e.g., "fab her" matches "Fabian Herbst")
|
||||||
|
$words = preg_split('/\s+/', trim($query));
|
||||||
|
$wordConditions = [];
|
||||||
|
|
||||||
|
foreach ($words as $word) {
|
||||||
|
if (strlen($word) < 1) continue;
|
||||||
|
$escapedWord = $db->escape($word);
|
||||||
|
$wordConditions[] = "(w.name LIKE '%{$escapedWord}%' OR w.email LIKE '%{$escapedWord}%')";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($wordConditions)) {
|
||||||
|
$sql .= " AND " . implode(' AND ', $wordConditions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " ORDER BY w.name ASC LIMIT 20";
|
||||||
|
|
||||||
|
$result = $db->query($sql);
|
||||||
|
$employees = [];
|
||||||
|
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$employees[] = [
|
||||||
|
'id' => intval($row['id']),
|
||||||
|
'name' => $row['name'],
|
||||||
|
'email' => $row['email'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::returnJson(['success' => true, 'employees' => $employees]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Calculate Haversine distance in meters
|
||||||
|
*/
|
||||||
|
private function haversineDistance($lat1, $lng1, $lat2, $lng2) {
|
||||||
|
$earthRadius = 6371000; // meters
|
||||||
|
|
||||||
|
$dLat = deg2rad($lat2 - $lat1);
|
||||||
|
$dLng = deg2rad($lng2 - $lng1);
|
||||||
|
|
||||||
|
$a = sin($dLat / 2) * sin($dLat / 2) +
|
||||||
|
cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
|
||||||
|
sin($dLng / 2) * sin($dLng / 2);
|
||||||
|
|
||||||
|
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
|
||||||
|
|
||||||
|
return $earthRadius * $c;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,929 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once APPDIR . 'MobileApp/Shared/MobileAppBaseHandler.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workorder (Aufträge) Handler
|
||||||
|
*
|
||||||
|
* Handles all endpoints for the Workorder module in MobileApp.
|
||||||
|
* API Base: /MobileApp/Workorder/Workorder/{action}
|
||||||
|
*
|
||||||
|
* Ports functionality from WorkorderCompanyController and WorkorderBaseController.
|
||||||
|
*/
|
||||||
|
class WorkorderHandler extends MobileAppBaseHandler {
|
||||||
|
|
||||||
|
protected $requiredPermission = 'RMLCompany';
|
||||||
|
|
||||||
|
/** @var array Status definitions for workorders */
|
||||||
|
protected $statusOptions = [
|
||||||
|
'new' => ['text' => 'Neu', 'color' => 'primary'],
|
||||||
|
'assigned' => ['text' => 'Zugewiesen', 'color' => 'info'],
|
||||||
|
'scheduled' => ['text' => 'Geplant', 'color' => 'warning'],
|
||||||
|
'in_progress' => ['text' => 'In Bearbeitung', 'color' => 'warning'],
|
||||||
|
'correction_requested' => ['text' => 'Korrektur angefordert', 'color' => 'danger'],
|
||||||
|
'intervention_required' => ['text' => 'Eingriff erforderlich', 'color' => 'danger'],
|
||||||
|
'civil_engineering_required' => ['text' => 'Tiefbau benötigt', 'color' => 'orange'],
|
||||||
|
'civil_engineering_completed' => ['text' => 'Tiefbau abgeschlossen', 'color' => 'success'],
|
||||||
|
'problem_solved' => ['text' => 'Problem gelöst', 'color' => 'success'],
|
||||||
|
'documented' => ['text' => 'Dokumentiert', 'color' => 'success'],
|
||||||
|
'completed' => ['text' => 'Abgeschlossen', 'color' => 'secondary'],
|
||||||
|
'charged' => ['text' => 'Verrechnet', 'color' => 'purple'],
|
||||||
|
'cancelled' => ['text' => 'Abgebrochen', 'color' => 'danger'],
|
||||||
|
'archived' => ['text' => 'Archiviert', 'color' => 'muted'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get workorders list for the company
|
||||||
|
* POST /MobileApp/Workorder/Workorder/get
|
||||||
|
*/
|
||||||
|
public function getAction() {
|
||||||
|
$postData = $this->getPostData();
|
||||||
|
|
||||||
|
$pagination = $postData['pagination'] ?? ['page' => 1, 'per_page' => 20];
|
||||||
|
$filters = $postData['filters'] ?? [];
|
||||||
|
$order = $postData['order'] ?? [];
|
||||||
|
$search = trim($postData['search'] ?? '');
|
||||||
|
|
||||||
|
// Get company for current user
|
||||||
|
$company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]);
|
||||||
|
if (!$company) {
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'workorders' => [],
|
||||||
|
'pagination' => ['page' => 1, 'per_page' => $pagination['per_page'], 'total' => 0]
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build workorders query
|
||||||
|
$workorders = WorkorderModel::getCompanyWorkorders(
|
||||||
|
$filters,
|
||||||
|
$pagination['per_page'],
|
||||||
|
($pagination['page'] - 1) * $pagination['per_page'],
|
||||||
|
$order,
|
||||||
|
$company->id,
|
||||||
|
$search
|
||||||
|
);
|
||||||
|
|
||||||
|
$totalCount = WorkorderModel::countCompanyWorkorders($filters, $company->id, $search);
|
||||||
|
|
||||||
|
// Transform for mobile app
|
||||||
|
$result = [];
|
||||||
|
foreach ($workorders as $wo) {
|
||||||
|
$result[] = $this->transformWorkorder($wo);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'workorders' => $result,
|
||||||
|
'pagination' => [
|
||||||
|
'page' => intval($pagination['page']),
|
||||||
|
'per_page' => intval($pagination['per_page']),
|
||||||
|
'total' => $totalCount,
|
||||||
|
'totalPages' => ceil($totalCount / $pagination['per_page'])
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single workorder details
|
||||||
|
* GET /MobileApp/Workorder/Workorder/getWorkorder?id=X
|
||||||
|
*/
|
||||||
|
public function getWorkorderAction() {
|
||||||
|
$id = intval($this->request->id ?? 0);
|
||||||
|
|
||||||
|
if (!$id) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'ID fehlt']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user has access
|
||||||
|
$company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]);
|
||||||
|
if (!$company) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Keine Berechtigung']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get workorder with full joined data
|
||||||
|
$workorder = $this->getWorkorderWithDetails($id, $company->id);
|
||||||
|
if (!$workorder) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'workorder' => $this->transformWorkorder($workorder, true)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get complete workorder detail (combined endpoint)
|
||||||
|
* Returns workorder, documentation, tenant config, and checklist in one request
|
||||||
|
* GET /MobileApp/Workorder/Workorder/getWorkorderDetail?id=X
|
||||||
|
*/
|
||||||
|
public function getWorkorderDetailAction() {
|
||||||
|
$id = intval($this->request->id ?? 0);
|
||||||
|
|
||||||
|
if (!$id) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'ID fehlt']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user has access
|
||||||
|
$company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]);
|
||||||
|
if (!$company) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Keine Berechtigung']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get workorder with full joined data
|
||||||
|
$workorderData = $this->getWorkorderWithDetails($id, $company->id);
|
||||||
|
if (!$workorderData) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workorder = $this->transformWorkorder($workorderData, true);
|
||||||
|
|
||||||
|
// Get tenant config
|
||||||
|
$tenantConfig = $this->getTenantConfigFromWorkorder($id);
|
||||||
|
$tenantConfigData = null;
|
||||||
|
$translationMap = [];
|
||||||
|
|
||||||
|
if ($tenantConfig) {
|
||||||
|
$customTypes = json_decode($tenantConfig->documentationTypes, true) ?? [];
|
||||||
|
$translationMap = array_merge(
|
||||||
|
['civil_engineering_photo' => 'Tiefbau_Foto'],
|
||||||
|
array_column($customTypes, 'text', 'value')
|
||||||
|
);
|
||||||
|
|
||||||
|
$tenantConfigData = [
|
||||||
|
'documentationTypes' => $customTypes,
|
||||||
|
'civilEngineeringDocsRequired' => (bool)$tenantConfig->civilEngineeringDocsRequired,
|
||||||
|
'interventionTypes' => json_decode($tenantConfig->interventionTypes, true) ?? [],
|
||||||
|
'requireCableLength' => (bool)$tenantConfig->requireCableLength,
|
||||||
|
'requireCableType' => (bool)$tenantConfig->requireCableType,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get documentation
|
||||||
|
$docs = WorkorderDocumentationModel::getAll(
|
||||||
|
['workorderId' => $id],
|
||||||
|
null, 0,
|
||||||
|
['key' => 'create', 'order' => 'ASC']
|
||||||
|
);
|
||||||
|
|
||||||
|
$typeCounts = [];
|
||||||
|
$responseDocs = [];
|
||||||
|
|
||||||
|
foreach ($docs as $doc) {
|
||||||
|
$file = new File($doc->fileId);
|
||||||
|
$documentTypeKey = $doc->documentType;
|
||||||
|
$typeCounts[$documentTypeKey] = ($typeCounts[$documentTypeKey] ?? 0) + 1;
|
||||||
|
$originalFilename = $file->orig_filename ?? $file->filename;
|
||||||
|
$extension = pathinfo($originalFilename, PATHINFO_EXTENSION);
|
||||||
|
$translatedType = $translationMap[$documentTypeKey] ?? $documentTypeKey;
|
||||||
|
$newFilename = "{$translatedType}_{$typeCounts[$documentTypeKey]}." . strtolower($extension);
|
||||||
|
|
||||||
|
$responseDocs[] = [
|
||||||
|
'id' => $doc->id,
|
||||||
|
'fileId' => $doc->fileId,
|
||||||
|
'fileName' => $newFilename,
|
||||||
|
'description' => $doc->description,
|
||||||
|
'documentType' => $documentTypeKey,
|
||||||
|
'documentTypeText' => $translationMap[$documentTypeKey] ?? $documentTypeKey,
|
||||||
|
'userName' => UserModel::getOne($doc->createBy)->name ?? 'Unbekannt',
|
||||||
|
'mimetype' => $file->mimetype ?? 'application/octet-stream',
|
||||||
|
'create' => $doc->create,
|
||||||
|
'createFormatted' => date('d.m.Y H:i', $doc->create),
|
||||||
|
'previewUrl' => "/File/Download/{$doc->fileId}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get journals
|
||||||
|
$journals = WorkorderJournalModel::getAll(
|
||||||
|
['workorderId' => $id],
|
||||||
|
null, 0,
|
||||||
|
['key' => 'create', 'order' => 'DESC']
|
||||||
|
);
|
||||||
|
|
||||||
|
$responseJournals = [];
|
||||||
|
foreach ($journals as $journal) {
|
||||||
|
$responseJournals[] = [
|
||||||
|
'id' => $journal->id,
|
||||||
|
'text' => $journal->text,
|
||||||
|
'statusChange' => $journal->statusChange ?? null,
|
||||||
|
'createByName' => UserModel::getOne($journal->createBy)->name ?? 'Unbekannt',
|
||||||
|
'create' => $journal->create,
|
||||||
|
'createFormatted' => date('d.m.Y H:i', $journal->create),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build checklist
|
||||||
|
$docTypes = $tenantConfigData['documentationTypes'] ?? [];
|
||||||
|
$uploadedTypes = array_column((array)$docs, 'documentType');
|
||||||
|
$uploadedTypeCounts = array_count_values($uploadedTypes);
|
||||||
|
|
||||||
|
$checklist = [];
|
||||||
|
$completedCount = 0;
|
||||||
|
|
||||||
|
foreach ($docTypes as $type) {
|
||||||
|
$isCompleted = isset($uploadedTypeCounts[$type['value']]) && $uploadedTypeCounts[$type['value']] > 0;
|
||||||
|
if ($isCompleted) $completedCount++;
|
||||||
|
|
||||||
|
$checklist[] = [
|
||||||
|
'type' => $type['value'],
|
||||||
|
'text' => $type['text'],
|
||||||
|
'required' => $type['required'] ?? false,
|
||||||
|
'completed' => $isCompleted,
|
||||||
|
'count' => $uploadedTypeCounts[$type['value']] ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'workorder' => $workorder,
|
||||||
|
'tenantConfig' => $tenantConfigData,
|
||||||
|
'docs' => $responseDocs,
|
||||||
|
'journals' => $responseJournals,
|
||||||
|
'checklist' => $checklist,
|
||||||
|
'checklistProgress' => [
|
||||||
|
'completed' => $completedCount,
|
||||||
|
'total' => count($docTypes),
|
||||||
|
'allRequired' => $this->allRequiredCompleted($checklist)
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get documentation and journals for a workorder
|
||||||
|
* GET /MobileApp/Workorder/Workorder/getDocumentation?workorderId=X
|
||||||
|
*/
|
||||||
|
public function getDocumentationAction() {
|
||||||
|
$workorderId = intval($this->request->workorderId ?? 0);
|
||||||
|
|
||||||
|
if (!$workorderId) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get documentation
|
||||||
|
$docs = WorkorderDocumentationModel::getAll(
|
||||||
|
['workorderId' => $workorderId],
|
||||||
|
null, 0,
|
||||||
|
['key' => 'create', 'order' => 'ASC']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get journals
|
||||||
|
$journals = WorkorderJournalModel::getAll(
|
||||||
|
['workorderId' => $workorderId],
|
||||||
|
null, 0,
|
||||||
|
['key' => 'create', 'order' => 'DESC']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get tenant config for type translations
|
||||||
|
$tenantConfig = $this->getTenantConfigFromWorkorder($workorderId);
|
||||||
|
$translationMap = [];
|
||||||
|
if ($tenantConfig && !empty($tenantConfig->documentationTypes)) {
|
||||||
|
$customTypes = json_decode($tenantConfig->documentationTypes, true);
|
||||||
|
$customMap = array_column($customTypes, 'text', 'value');
|
||||||
|
$translationMap = array_merge(['civil_engineering_photo' => 'Tiefbau_Foto'], $customMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform docs
|
||||||
|
$responseDocs = [];
|
||||||
|
$typeCounts = [];
|
||||||
|
|
||||||
|
foreach ($docs as $doc) {
|
||||||
|
$file = new File($doc->fileId);
|
||||||
|
$documentTypeKey = $doc->documentType;
|
||||||
|
$typeCounts[$documentTypeKey] = ($typeCounts[$documentTypeKey] ?? 0) + 1;
|
||||||
|
$originalFilename = $file->orig_filename ?? $file->filename;
|
||||||
|
$extension = pathinfo($originalFilename, PATHINFO_EXTENSION);
|
||||||
|
$translatedType = $translationMap[$documentTypeKey] ?? $documentTypeKey;
|
||||||
|
$newFilename = "{$translatedType}_{$typeCounts[$documentTypeKey]}." . strtolower($extension);
|
||||||
|
|
||||||
|
$responseDocs[] = [
|
||||||
|
'id' => $doc->id,
|
||||||
|
'fileId' => $doc->fileId,
|
||||||
|
'fileName' => $newFilename,
|
||||||
|
'description' => $doc->description,
|
||||||
|
'documentType' => $documentTypeKey,
|
||||||
|
'documentTypeText' => $translationMap[$documentTypeKey] ?? $documentTypeKey,
|
||||||
|
'userName' => UserModel::getOne($doc->createBy)->name ?? 'Unbekannt',
|
||||||
|
'mimetype' => $file->mimetype ?? 'application/octet-stream',
|
||||||
|
'create' => $doc->create,
|
||||||
|
'createFormatted' => date('d.m.Y H:i', $doc->create),
|
||||||
|
'previewUrl' => "/File/Download/{$doc->fileId}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform journals
|
||||||
|
$responseJournals = [];
|
||||||
|
foreach ($journals as $journal) {
|
||||||
|
$responseJournals[] = [
|
||||||
|
'id' => $journal->id,
|
||||||
|
'text' => $journal->text,
|
||||||
|
'statusChange' => $journal->statusChange ?? null,
|
||||||
|
'createByName' => UserModel::getOne($journal->createBy)->name ?? 'Unbekannt',
|
||||||
|
'create' => $journal->create,
|
||||||
|
'createFormatted' => date('d.m.Y H:i', $journal->create),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'docs' => $responseDocs,
|
||||||
|
'journals' => $responseJournals,
|
||||||
|
'docCount' => count($responseDocs),
|
||||||
|
'journalCount' => count($responseJournals)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tenant configuration
|
||||||
|
* GET /MobileApp/Workorder/Workorder/getTenantConfig?workorderId=X
|
||||||
|
*/
|
||||||
|
public function getTenantConfigAction() {
|
||||||
|
$workorderId = intval($this->request->workorderId ?? 0);
|
||||||
|
|
||||||
|
$tenantConfig = $this->getTenantConfigFromWorkorder($workorderId);
|
||||||
|
|
||||||
|
if (!$tenantConfig) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Keine Mandantenkonfiguration gefunden']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'documentationTypes' => json_decode($tenantConfig->documentationTypes, true) ?? [],
|
||||||
|
'civilEngineeringDocsRequired' => (bool)$tenantConfig->civilEngineeringDocsRequired,
|
||||||
|
'interventionTypes' => json_decode($tenantConfig->interventionTypes, true) ?? [],
|
||||||
|
'requireCableLength' => (bool)$tenantConfig->requireCableLength,
|
||||||
|
'requireCableType' => (bool)$tenantConfig->requireCableType,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload documentation files
|
||||||
|
* POST /MobileApp/Workorder/Workorder/uploadDocumentation
|
||||||
|
*/
|
||||||
|
public function uploadDocumentationAction() {
|
||||||
|
if (empty($_FILES['files']) && empty($_FILES['file'])) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Keine Datei hochgeladen']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workorderId = intval($_POST['workorderId'] ?? 0);
|
||||||
|
if (!$workorderId) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$documentType = $_POST['documentType'] ?? 'general';
|
||||||
|
$description = $_POST['description'] ?? '';
|
||||||
|
|
||||||
|
// Handle both single file and multiple files
|
||||||
|
if (!empty($_FILES['files'])) {
|
||||||
|
foreach ($_FILES['files']['name'] as $index => $name) {
|
||||||
|
if ($_FILES['files']['error'][$index] === UPLOAD_ERR_OK) {
|
||||||
|
$_FILES['file'] = [
|
||||||
|
'name' => $name,
|
||||||
|
'type' => $_FILES['files']['type'][$index],
|
||||||
|
'tmp_name' => $_FILES['files']['tmp_name'][$index],
|
||||||
|
'error' => $_FILES['files']['error'][$index],
|
||||||
|
'size' => $_FILES['files']['size'][$index]
|
||||||
|
];
|
||||||
|
$this->saveDocumentation($workorderId, $documentType, $description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!empty($_FILES['file'])) {
|
||||||
|
$this->saveDocumentation($workorderId, $documentType, $description);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update workorder status if needed
|
||||||
|
$workorder = WorkorderModel::get($workorderId);
|
||||||
|
$oldStatus = $workorder->status;
|
||||||
|
$newStatus = null;
|
||||||
|
|
||||||
|
if (in_array($oldStatus, ['assigned', 'scheduled'])) {
|
||||||
|
$newStatus = 'in_progress';
|
||||||
|
} else if (in_array($oldStatus, ['correction_requested', 'problem_solved', 'civil_engineering_completed'])) {
|
||||||
|
$newStatus = 'assigned';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($newStatus) {
|
||||||
|
$workorder->status = $newStatus;
|
||||||
|
WorkorderModel::update((array)$workorder);
|
||||||
|
|
||||||
|
WorkorderJournalModel::create([
|
||||||
|
'workorderId' => $workorder->id,
|
||||||
|
'text' => 'Status wurde nach Dokumenten-Upload automatisch geändert.',
|
||||||
|
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText($newStatus),
|
||||||
|
'create' => time(),
|
||||||
|
'createBy' => $this->user->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::returnJson(['success' => true, 'message' => 'Datei(en) erfolgreich hochgeladen']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete documentation
|
||||||
|
* POST /MobileApp/Workorder/Workorder/deleteDocumentation
|
||||||
|
*/
|
||||||
|
public function deleteDocumentationAction() {
|
||||||
|
$postData = $this->getPostData();
|
||||||
|
$id = intval($postData['id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$id) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Dokumenten-ID fehlt']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkorderDocumentationModel::delete($id);
|
||||||
|
self::returnJson(['success' => true, 'message' => 'Dokument gelöscht']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add journal entry
|
||||||
|
* POST /MobileApp/Workorder/Workorder/addJournal
|
||||||
|
*/
|
||||||
|
public function addJournalAction() {
|
||||||
|
$postData = $this->getPostData();
|
||||||
|
$workorderId = intval($postData['workorderId'] ?? 0);
|
||||||
|
$text = trim($postData['text'] ?? '');
|
||||||
|
|
||||||
|
if (!$workorderId || !$text) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID und Text sind erforderlich']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkorderJournalModel::create([
|
||||||
|
'workorderId' => $workorderId,
|
||||||
|
'text' => $text,
|
||||||
|
'createBy' => $this->user->id,
|
||||||
|
'create' => time()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Return updated journals
|
||||||
|
$journals = WorkorderJournalModel::getAll(
|
||||||
|
['workorderId' => $workorderId],
|
||||||
|
null, 0,
|
||||||
|
['key' => 'create', 'order' => 'DESC']
|
||||||
|
);
|
||||||
|
|
||||||
|
$responseJournals = [];
|
||||||
|
foreach ($journals as $journal) {
|
||||||
|
$responseJournals[] = [
|
||||||
|
'id' => $journal->id,
|
||||||
|
'text' => $journal->text,
|
||||||
|
'statusChange' => $journal->statusChange ?? null,
|
||||||
|
'createByName' => UserModel::getOne($journal->createBy)->name ?? 'Unbekannt',
|
||||||
|
'create' => $journal->create,
|
||||||
|
'createFormatted' => date('d.m.Y H:i', $journal->create),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Journaleintrag hinzugefügt',
|
||||||
|
'journals' => $responseJournals
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update additional info (notes)
|
||||||
|
* POST /MobileApp/Workorder/Workorder/updateAdditionalInfo
|
||||||
|
*/
|
||||||
|
public function updateAdditionalInfoAction() {
|
||||||
|
$postData = $this->getPostData();
|
||||||
|
$workorderId = intval($postData['workorderId'] ?? 0);
|
||||||
|
|
||||||
|
if (!$workorderId) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workorder = WorkorderModel::get($workorderId);
|
||||||
|
if (!$workorder) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$oldInfo = $workorder->additionalInfo;
|
||||||
|
$newInfo = $postData['additionalInfo'] ?? null;
|
||||||
|
$workorder->additionalInfo = $newInfo;
|
||||||
|
WorkorderModel::update((array)$workorder);
|
||||||
|
|
||||||
|
WorkorderJournalModel::create([
|
||||||
|
'workorderId' => $workorder->id,
|
||||||
|
'text' => "Zusatzinfo geändert.\nAlt: '{$oldInfo}'\nNeu: '{$newInfo}'",
|
||||||
|
'create' => time(),
|
||||||
|
'createBy' => $this->user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Zusatzinfo aktualisiert',
|
||||||
|
'newInfo' => $newInfo
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule appointment
|
||||||
|
* POST /MobileApp/Workorder/Workorder/scheduleAppointment
|
||||||
|
*/
|
||||||
|
public function scheduleAppointmentAction() {
|
||||||
|
$postData = $this->getPostData();
|
||||||
|
$workorderId = intval($postData['workorderId'] ?? 0);
|
||||||
|
$appointmentDate = intval($postData['appointmentDate'] ?? 0);
|
||||||
|
|
||||||
|
if (!$workorderId || !$appointmentDate) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Erforderliche Felder fehlen']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workorder = WorkorderModel::get($workorderId);
|
||||||
|
if (!$workorder) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate time is set
|
||||||
|
$hour = (int)date('H', $appointmentDate);
|
||||||
|
if ($hour >= 23 || $hour < 1) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Bitte geben Sie eine Uhrzeit an']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workorder->appointmentDate = $appointmentDate;
|
||||||
|
$workorder->status = 'scheduled';
|
||||||
|
WorkorderModel::update((array)$workorder);
|
||||||
|
|
||||||
|
WorkorderJournalModel::create([
|
||||||
|
'workorderId' => $workorder->id,
|
||||||
|
'text' => 'Termin festgelegt auf: ' . date('d.m.Y H:i', $appointmentDate),
|
||||||
|
'create' => time(),
|
||||||
|
'createBy' => $this->user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::returnJson(['success' => true, 'message' => 'Termin erfolgreich gespeichert']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request intervention (report problem)
|
||||||
|
* POST /MobileApp/Workorder/Workorder/requestIntervention
|
||||||
|
*/
|
||||||
|
public function requestInterventionAction() {
|
||||||
|
$postData = $this->getPostData();
|
||||||
|
$workorderId = intval($postData['workorderId'] ?? 0);
|
||||||
|
$journalText = trim($postData['journalText'] ?? '');
|
||||||
|
$interventionType = $postData['interventionType'] ?? '';
|
||||||
|
|
||||||
|
if (!$workorderId || !$journalText) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Erforderliche Felder fehlen']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workorder = WorkorderModel::get($workorderId);
|
||||||
|
if (!$workorder) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$oldStatus = $workorder->status;
|
||||||
|
$workorder->status = 'intervention_required';
|
||||||
|
WorkorderModel::update((array)$workorder);
|
||||||
|
|
||||||
|
$fullText = $interventionType ? "{$interventionType}: {$journalText}" : "Eingriff erforderlich: {$journalText}";
|
||||||
|
|
||||||
|
WorkorderJournalModel::create([
|
||||||
|
'workorderId' => $workorder->id,
|
||||||
|
'text' => $fullText,
|
||||||
|
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('intervention_required'),
|
||||||
|
'create' => time(),
|
||||||
|
'createBy' => $this->user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::returnJson(['success' => true, 'message' => 'Eingriff wurde angefordert']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete workorder
|
||||||
|
* POST /MobileApp/Workorder/Workorder/completeWorkorder
|
||||||
|
*/
|
||||||
|
public function completeWorkorderAction() {
|
||||||
|
$postData = $this->getPostData();
|
||||||
|
$workorderId = intval($postData['workorderId'] ?? 0);
|
||||||
|
|
||||||
|
if (!$workorderId) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workorder = WorkorderModel::get($workorderId);
|
||||||
|
if (!$workorder) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate cable data if required
|
||||||
|
$tenantConfig = $this->getTenantConfigFromWorkorder($workorderId);
|
||||||
|
if ($tenantConfig) {
|
||||||
|
if ($tenantConfig->requireCableLength && empty(trim($workorder->cableLength))) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Bitte geben Sie die Kabellänge an']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($tenantConfig->requireCableType && empty(trim($workorder->cableType))) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Bitte geben Sie den Kabeltyp an']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$oldStatus = $workorder->status;
|
||||||
|
$workorder->status = 'documented';
|
||||||
|
WorkorderModel::update((array)$workorder);
|
||||||
|
|
||||||
|
WorkorderJournalModel::create([
|
||||||
|
'workorderId' => $workorder->id,
|
||||||
|
'text' => 'Arbeitsauftrag zur Prüfung eingereicht.',
|
||||||
|
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('documented'),
|
||||||
|
'create' => time(),
|
||||||
|
'createBy' => $this->user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag zur Prüfung eingereicht']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update workorder data (cable info)
|
||||||
|
* POST /MobileApp/Workorder/Workorder/updateWorkorderData
|
||||||
|
*/
|
||||||
|
public function updateWorkorderDataAction() {
|
||||||
|
$postData = $this->getPostData();
|
||||||
|
$workorderId = intval($postData['workorderId'] ?? 0);
|
||||||
|
|
||||||
|
if (!$workorderId) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workorder = WorkorderModel::get($workorderId);
|
||||||
|
if (!$workorder) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$journalText = "Zusatzdaten aktualisiert:\n";
|
||||||
|
$changed = false;
|
||||||
|
|
||||||
|
if (isset($postData['cableLength'])) {
|
||||||
|
if ($workorder->cableLength != $postData['cableLength']) {
|
||||||
|
$journalText .= "Kabellänge: '{$workorder->cableLength}' -> '{$postData['cableLength']}'\n";
|
||||||
|
$workorder->cableLength = $postData['cableLength'];
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($postData['cableType'])) {
|
||||||
|
if ($workorder->cableType != $postData['cableType']) {
|
||||||
|
$journalText .= "Kabeltyp: '{$workorder->cableType}' -> '{$postData['cableType']}'\n";
|
||||||
|
$workorder->cableType = $postData['cableType'];
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$changed) {
|
||||||
|
self::returnJson(['success' => true, 'message' => 'Keine Änderungen vorgenommen']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkorderModel::update((array)$workorder);
|
||||||
|
|
||||||
|
WorkorderJournalModel::create([
|
||||||
|
'workorderId' => $workorder->id,
|
||||||
|
'text' => $journalText,
|
||||||
|
'create' => time(),
|
||||||
|
'createBy' => $this->user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::returnJson(['success' => true, 'message' => 'Daten gespeichert']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get checklist status for a workorder
|
||||||
|
* GET /MobileApp/Workorder/Workorder/getChecklist?workorderId=X
|
||||||
|
*/
|
||||||
|
public function getChecklistAction() {
|
||||||
|
$workorderId = intval($this->request->workorderId ?? 0);
|
||||||
|
|
||||||
|
if (!$workorderId) {
|
||||||
|
self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tenant config for required doc types
|
||||||
|
$tenantConfig = $this->getTenantConfigFromWorkorder($workorderId);
|
||||||
|
$docTypes = $tenantConfig ? json_decode($tenantConfig->documentationTypes, true) : [];
|
||||||
|
|
||||||
|
// Get existing documentation
|
||||||
|
$docs = WorkorderDocumentationModel::getAll(['workorderId' => $workorderId]);
|
||||||
|
$uploadedTypes = array_column((array)$docs, 'documentType');
|
||||||
|
$uploadedTypeCounts = array_count_values($uploadedTypes);
|
||||||
|
|
||||||
|
// Build checklist
|
||||||
|
$checklist = [];
|
||||||
|
$completedCount = 0;
|
||||||
|
|
||||||
|
foreach ($docTypes as $type) {
|
||||||
|
$isCompleted = isset($uploadedTypeCounts[$type['value']]) && $uploadedTypeCounts[$type['value']] > 0;
|
||||||
|
if ($isCompleted) $completedCount++;
|
||||||
|
|
||||||
|
$checklist[] = [
|
||||||
|
'type' => $type['value'],
|
||||||
|
'text' => $type['text'],
|
||||||
|
'required' => $type['required'] ?? false,
|
||||||
|
'completed' => $isCompleted,
|
||||||
|
'count' => $uploadedTypeCounts[$type['value']] ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'checklist' => $checklist,
|
||||||
|
'completed' => $completedCount,
|
||||||
|
'total' => count($docTypes),
|
||||||
|
'allRequired' => $this->allRequiredCompleted($checklist)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// HELPER METHODS
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform workorder for API response
|
||||||
|
* @param array|object $wo Workorder data (can be array or object from getCompanyWorkorders)
|
||||||
|
* @param bool $detailed Include full customer details
|
||||||
|
*/
|
||||||
|
private function transformWorkorder($wo, $detailed = false) {
|
||||||
|
// Handle both array and object formats
|
||||||
|
$isArray = is_array($wo);
|
||||||
|
$get = function($key, $default = null) use ($wo, $isArray) {
|
||||||
|
if ($isArray) {
|
||||||
|
return $wo[$key] ?? $default;
|
||||||
|
}
|
||||||
|
return $wo->$key ?? $default;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Customer name: use company if available, else customerName (firstname lastname)
|
||||||
|
$customerCompany = $get('customerCompany', '');
|
||||||
|
$customerName = $customerCompany ?: $get('customerName', '');
|
||||||
|
|
||||||
|
// Build address from the joined data
|
||||||
|
$street = $get('street', '');
|
||||||
|
$hausnummer = $get('hausnummer', '');
|
||||||
|
$plz = $get('plz', '');
|
||||||
|
$city = $get('city', '');
|
||||||
|
$customerAddress = trim("{$street} {$hausnummer}, {$plz} {$city}", ', ');
|
||||||
|
|
||||||
|
$status = $get('status');
|
||||||
|
$appointmentDate = $get('appointmentDate');
|
||||||
|
$deadlineDate = $get('deadlineDate');
|
||||||
|
$cableType = $get('cableType', '');
|
||||||
|
$cableLength = $get('cableLength', '');
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'id' => intval($get('id', 0)),
|
||||||
|
'fcpName' => $get('rimo_fcp_name', ''),
|
||||||
|
'oaid' => $get('oaid', ''),
|
||||||
|
'status' => $status,
|
||||||
|
'statusText' => $this->statusOptions[$status]['text'] ?? $status,
|
||||||
|
'statusColor' => $this->statusOptions[$status]['color'] ?? 'secondary',
|
||||||
|
'customerName' => $customerName,
|
||||||
|
'customerAddress' => $customerAddress,
|
||||||
|
'additionalInfo' => $get('additionalInfo', ''),
|
||||||
|
'appointmentDate' => $appointmentDate ? intval($appointmentDate) : null,
|
||||||
|
'appointmentFormatted' => $appointmentDate ? date('d.m.Y H:i', $appointmentDate) : null,
|
||||||
|
'deadlineDate' => $deadlineDate ? intval($deadlineDate) : null,
|
||||||
|
'deadlineFormatted' => $deadlineDate ? date('d.m.Y', $deadlineDate) : null,
|
||||||
|
'cableType' => $cableType,
|
||||||
|
'cableLength' => $cableLength,
|
||||||
|
'hasCableFlag' => !empty($cableType) || !empty($cableLength),
|
||||||
|
];
|
||||||
|
|
||||||
|
// For detailed view (single workorder), include customer contact info
|
||||||
|
if ($detailed) {
|
||||||
|
$result['customer'] = [
|
||||||
|
'id' => intval($get('id', 0)),
|
||||||
|
'name' => $customerName,
|
||||||
|
'street' => trim("{$street} {$hausnummer}"),
|
||||||
|
'zip' => $plz,
|
||||||
|
'city' => $city,
|
||||||
|
'phone' => $get('phone', ''),
|
||||||
|
'email' => $get('email', ''),
|
||||||
|
'gpsLat' => null, // Not available in this query
|
||||||
|
'gpsLng' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$result['campaign'] = $get('networkOwnerName', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status display text
|
||||||
|
*/
|
||||||
|
private function getStatusText($statusKey) {
|
||||||
|
return $this->statusOptions[$statusKey]['text'] ?? ucfirst(str_replace('_', ' ', $statusKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tenant config from workorder
|
||||||
|
*/
|
||||||
|
private function getTenantConfigFromWorkorder($workorderId) {
|
||||||
|
if (!$workorderId) return null;
|
||||||
|
|
||||||
|
$workorder = WorkorderModel::get($workorderId);
|
||||||
|
if (!$workorder) return null;
|
||||||
|
|
||||||
|
$preorder = new Preorder($workorder->preorderId);
|
||||||
|
if (!$preorder->id) return null;
|
||||||
|
|
||||||
|
$campaign = new Preordercampaign($preorder->preordercampaign_id);
|
||||||
|
if (!$campaign->id) return null;
|
||||||
|
|
||||||
|
$network = NetworkModel::getOne($campaign->network_id);
|
||||||
|
if (!$network) return null;
|
||||||
|
|
||||||
|
return WorkorderTenantConfigModel::getFirst(['addressId' => $network->owner_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save documentation file
|
||||||
|
*/
|
||||||
|
private function saveDocumentation($workorderId, $documentType, $description) {
|
||||||
|
try {
|
||||||
|
$uploaded = mfUpload::handleFormUpload("file", false, "/Workorder");
|
||||||
|
WorkorderDocumentationModel::create([
|
||||||
|
'workorderId' => $workorderId,
|
||||||
|
'fileId' => $uploaded->id,
|
||||||
|
'description' => $description,
|
||||||
|
'documentType' => $documentType,
|
||||||
|
'create' => time(),
|
||||||
|
'createBy' => $this->user->id
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Log error if necessary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if all required checklist items are completed
|
||||||
|
*/
|
||||||
|
private function allRequiredCompleted($checklist) {
|
||||||
|
foreach ($checklist as $item) {
|
||||||
|
if ($item['required'] && !$item['completed']) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single workorder with full joined data (same structure as getCompanyWorkorders)
|
||||||
|
*/
|
||||||
|
private function getWorkorderWithDetails($workorderId, $companyId) {
|
||||||
|
$db = $this->db();
|
||||||
|
$fronkDbName = FRONKDB_DBNAME;
|
||||||
|
$addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT w.id, w.status, w.deadlineDate, w.appointmentDate, w.additionalInfo,
|
||||||
|
w.cableType, w.cableLength, hn.rimo_fcp_name,
|
||||||
|
owner_addr.company as networkOwnerName, p.preordercampaign_id,
|
||||||
|
CONCAT_WS(' ', p.firstname, p.lastname) as customerName, p.company as customerCompany, p.oaid,
|
||||||
|
p.phone, p.email, str.name as street, hn.hausnummer, hn.stiege, we.bezeichner as apartment,
|
||||||
|
plz.plz, ort.name as city
|
||||||
|
FROM `{$fronkDbName}`.`Workorder` w
|
||||||
|
JOIN `{$fronkDbName}`.`Preorder` p ON w.preorderId = p.id
|
||||||
|
LEFT JOIN `{$fronkDbName}`.`Preordercampaign` pc ON p.preordercampaign_id = pc.id
|
||||||
|
LEFT JOIN `{$fronkDbName}`.`Network` n ON pc.network_id = n.id
|
||||||
|
LEFT JOIN `{$fronkDbName}`.`Address` owner_addr ON n.owner_id = owner_addr.id
|
||||||
|
LEFT JOIN `{$addressDbName}`.`Hausnummer` hn ON p.adb_hausnummer_id = hn.id
|
||||||
|
LEFT JOIN `{$addressDbName}`.`Strasse` str ON hn.strasse_id = str.id
|
||||||
|
LEFT JOIN `{$addressDbName}`.`Plz` plz ON hn.plz_id = plz.id
|
||||||
|
LEFT JOIN `{$addressDbName}`.`Ortschaft` ort ON hn.ortschaft_id = ort.id
|
||||||
|
LEFT JOIN `{$addressDbName}`.`Wohneinheit` we ON p.adb_wohneinheit_id = we.id
|
||||||
|
WHERE w.id = " . intval($workorderId) . "
|
||||||
|
AND w.companyId = " . intval($companyId) . "
|
||||||
|
LIMIT 1
|
||||||
|
";
|
||||||
|
|
||||||
|
$result = $db->query($sql);
|
||||||
|
return $result ? $result->fetch_assoc() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -184,34 +184,21 @@ class WarehouseMovementController extends TTCrud {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected function formatRow($row) {
|
protected function formatRow($row) {
|
||||||
// Format movement type with badge
|
$rawType = $row['movementType'];
|
||||||
$typeLabels = [
|
|
||||||
'IN' => '<span class="badge bg-success">Einbuchung</span>',
|
|
||||||
'OUT' => '<span class="badge bg-danger">Ausbuchung</span>',
|
|
||||||
'ADJUSTMENT' => '<span class="badge bg-warning">Korrektur</span>',
|
|
||||||
];
|
|
||||||
$row['movementType'] = $typeLabels[$row['movementType']] ?? $row['movementType'];
|
|
||||||
|
|
||||||
// Format article
|
|
||||||
if (!empty($row['articleId'])) {
|
if (!empty($row['articleId'])) {
|
||||||
$article = ArticleModel::get($row['articleId']);
|
$article = WarehouseArticleModel::get($row['articleId']);
|
||||||
if ($article) {
|
if ($article) {
|
||||||
$row['articleId'] = "<strong>{$article->articleNumber}</strong><br><small class='text-muted'>{$article->title}</small>";
|
$row['articleId'] = "<strong>{$article->articleNumber}</strong><br><small class='text-muted'>{$article->title}</small>";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format quantities
|
|
||||||
$row['quantityBefore'] = $row['quantityBefore'] !== null ? number_format((float)$row['quantityBefore'], 2, ',', '.') : '-';
|
$row['quantityBefore'] = $row['quantityBefore'] !== null ? number_format((float)$row['quantityBefore'], 2, ',', '.') : '-';
|
||||||
$row['quantityAfter'] = $row['quantityAfter'] !== null ? number_format((float)$row['quantityAfter'], 2, ',', '.') : '-';
|
$row['quantityAfter'] = $row['quantityAfter'] !== null ? number_format((float)$row['quantityAfter'], 2, ',', '.') : '-';
|
||||||
$row['quantity'] = number_format((float)$row['quantity'], 2, ',', '.');
|
$row['quantity'] = number_format((float)$row['quantity'], 2, ',', '.');
|
||||||
|
|
||||||
// Format reason category
|
$allCategories = WarehouseMovementModel::getReasonCategories();
|
||||||
$row['reasonCategory'] = WarehouseMovementModel::getReasonCategories()[$row['movementType']][$row['reasonCategory']] ?? $row['reasonCategory'];
|
$row['reasonCategory'] = $allCategories[$rawType][$row['reasonCategory']] ?? $row['reasonCategory'];
|
||||||
|
|
||||||
// Format create date
|
|
||||||
if (!empty($row['create'])) {
|
|
||||||
$row['create'] = date('d.m.Y H:i', $row['create']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $row;
|
return $row;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,16 +12,14 @@ class WarehouseMovementModel extends TTCrudBaseModel {
|
|||||||
public ?float $quantityAfter = null;
|
public ?float $quantityAfter = null;
|
||||||
public string $reasonCategory;
|
public string $reasonCategory;
|
||||||
public ?string $note = null;
|
public ?string $note = null;
|
||||||
|
public ?int $linkedOrderId = null;
|
||||||
public int $userId;
|
public int $userId;
|
||||||
public int $createBy;
|
public int $createBy;
|
||||||
public int $create;
|
public int $create;
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate next movement number (WM-YYYY-X000001)
|
|
||||||
*/
|
|
||||||
public static function generateMovementNumber(): string {
|
public static function generateMovementNumber(): string {
|
||||||
$year = date('Y');
|
$year = date('Y');
|
||||||
$prefix = "WM-{$year}-X";
|
$prefix = "LB{$year}-X";
|
||||||
|
|
||||||
$db = FronkDB::singleton();
|
$db = FronkDB::singleton();
|
||||||
$result = $db->query("SELECT movementNumber FROM WarehouseMovement
|
$result = $db->query("SELECT movementNumber FROM WarehouseMovement
|
||||||
@@ -85,11 +83,8 @@ class WarehouseMovementModel extends TTCrudBaseModel {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function getArticle(): ?WarehouseArticleModel {
|
||||||
* Get article object
|
return WarehouseArticleModel::get($this->articleId);
|
||||||
*/
|
|
||||||
public function getArticle(): ?ArticleModel {
|
|
||||||
return ArticleModel::get($this->articleId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,6 +109,14 @@ class WarehouseMovementModel extends TTCrudBaseModel {
|
|||||||
return WarehouseItemModel::get($this->warehouseItemId);
|
return WarehouseItemModel::get($this->warehouseItemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get linked order if this movement was created from an order delivery
|
||||||
|
*/
|
||||||
|
public function getLinkedOrder(): ?WarehouseOrderModel {
|
||||||
|
if (!$this->linkedOrderId) return null;
|
||||||
|
return WarehouseOrderModel::get($this->linkedOrderId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get formatted movement type label
|
* Get formatted movement type label
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -406,9 +406,72 @@ $appendToBody
|
|||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$createdMovementIds = [];
|
||||||
|
|
||||||
|
// Create warehouse movements for delivery statuses
|
||||||
|
if (in_array($postData['status'], ['partiallyDelivered', 'fullyDelivered'])
|
||||||
|
&& isset($postData['deliveryData']) && is_array($postData['deliveryData'])) {
|
||||||
|
|
||||||
|
// Get location ID from request or use default (K1 Fladnitz 150)
|
||||||
|
$locationId = intval($postData['locationId'] ?? 0);
|
||||||
|
if ($locationId <= 0) {
|
||||||
|
// Default to K1 Fladnitz 150
|
||||||
|
$allLocations = WarehouseLocationModel::getAll();
|
||||||
|
$defaultLocation = null;
|
||||||
|
foreach ($allLocations as $loc) {
|
||||||
|
if ($loc->title === 'K1 Fladnitz 150') {
|
||||||
|
$defaultLocation = $loc;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$locationId = $defaultLocation ? $defaultLocation->id : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare delivery data with articleId from order positions
|
||||||
|
$positions = json_decode($order->positions, true) ?: [];
|
||||||
|
$deliveryDataWithArticleIds = [];
|
||||||
|
|
||||||
|
foreach ($postData['deliveryData'] as $index => $delivery) {
|
||||||
|
if (isset($positions[$index])) {
|
||||||
|
$delivery['articleId'] = $positions[$index]['article'];
|
||||||
|
}
|
||||||
|
$deliveryDataWithArticleIds[] = $delivery;
|
||||||
|
}
|
||||||
|
|
||||||
|
$createdMovementIds = $this->createMovementsForDelivery(
|
||||||
|
intval($postData['orderId']),
|
||||||
|
$deliveryDataWithArticleIds,
|
||||||
|
$locationId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!empty($createdMovementIds)) {
|
||||||
|
// Update order with linked movement IDs
|
||||||
|
$existingMovementIds = $order->linkedMovementIds
|
||||||
|
? json_decode($order->linkedMovementIds, true) : [];
|
||||||
|
$allMovementIds = array_merge($existingMovementIds, $createdMovementIds);
|
||||||
|
$orderAsArray['linkedMovementIds'] = json_encode($allMovementIds);
|
||||||
|
|
||||||
|
// Add movement info to log message
|
||||||
|
$fullLogMessage .= ($fullLogMessage ? "\n\n" : "") .
|
||||||
|
count($createdMovementIds) . " Lagerbewegung(en) erstellt.";
|
||||||
|
$log['message'] = trim($fullLogMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store delivery note file IDs
|
||||||
|
if (!empty($postData['deliveryNoteFileIds'])) {
|
||||||
|
$existingFileIds = $order->deliveryNoteFileIds
|
||||||
|
? json_decode($order->deliveryNoteFileIds, true) : [];
|
||||||
|
$allFileIds = array_merge($existingFileIds, $postData['deliveryNoteFileIds']);
|
||||||
|
$orderAsArray['deliveryNoteFileIds'] = json_encode($allFileIds);
|
||||||
|
}
|
||||||
|
|
||||||
if ($postData['status'] !== 'noChanges') {
|
if ($postData['status'] !== 'noChanges') {
|
||||||
$orderAsArray['status'] = $postData['status'];
|
$orderAsArray['status'] = $postData['status'];
|
||||||
WarehouseOrderModel::update($orderAsArray);
|
WarehouseOrderModel::update($orderAsArray);
|
||||||
|
} elseif (!empty($orderAsArray['linkedMovementIds']) || !empty($orderAsArray['deliveryNoteFileIds'])) {
|
||||||
|
// Update even if status didn't change but we added linked data
|
||||||
|
WarehouseOrderModel::update($orderAsArray);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only create a log entry if there's actually something to log
|
// Only create a log entry if there's actually something to log
|
||||||
@@ -416,7 +479,11 @@ $appendToBody
|
|||||||
WarehouseLogModel::create($log);
|
WarehouseLogModel::create($log);
|
||||||
}
|
}
|
||||||
|
|
||||||
self::returnJson(['success' => true, 'message' => 'Log entry created']);
|
self::returnJson([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Log entry created',
|
||||||
|
'createdMovementIds' => $createdMovementIds
|
||||||
|
]);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
self::returnJson(['error' => 'Error creating log entry: ' . $e->getMessage()]);
|
self::returnJson(['error' => 'Error creating log entry: ' . $e->getMessage()]);
|
||||||
}
|
}
|
||||||
@@ -485,6 +552,107 @@ $appendToBody
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function createMovementsForDelivery(int $orderId, array $deliveryData, int $locationId): array {
|
||||||
|
$order = WarehouseOrderModel::get($orderId);
|
||||||
|
$createdMovementIds = [];
|
||||||
|
|
||||||
|
foreach ($deliveryData as $delivery) {
|
||||||
|
$deliveredAmount = floatval($delivery['amount']);
|
||||||
|
$articleId = intval($delivery['articleId']);
|
||||||
|
|
||||||
|
// Only create movements for items actually delivered
|
||||||
|
if ($deliveredAmount <= 0 || $articleId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create WarehouseItem for article + location
|
||||||
|
$existingItems = WarehouseItemModel::getAll([
|
||||||
|
'articleId' => $articleId,
|
||||||
|
'warehouseLocationId' => $locationId
|
||||||
|
]);
|
||||||
|
$warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null;
|
||||||
|
|
||||||
|
if (!$warehouseItem) {
|
||||||
|
// Create new warehouse item with zero quantity
|
||||||
|
$warehouseItemId = WarehouseItemModel::create([
|
||||||
|
'articleId' => $articleId,
|
||||||
|
'warehouseLocationId' => $locationId,
|
||||||
|
'quantity' => 0
|
||||||
|
]);
|
||||||
|
$warehouseItem = WarehouseItemModel::get($warehouseItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$quantityBefore = $warehouseItem->quantity;
|
||||||
|
$quantityAfter = $quantityBefore + $deliveredAmount;
|
||||||
|
|
||||||
|
// Create warehouse movement
|
||||||
|
$movementData = [
|
||||||
|
'movementNumber' => WarehouseMovementModel::generateMovementNumber(),
|
||||||
|
'movementType' => 'IN',
|
||||||
|
'articleId' => $articleId,
|
||||||
|
'warehouseLocationId' => $locationId,
|
||||||
|
'warehouseItemId' => $warehouseItem->id,
|
||||||
|
'quantity' => $deliveredAmount,
|
||||||
|
'quantityBefore' => $quantityBefore,
|
||||||
|
'quantityAfter' => $quantityAfter,
|
||||||
|
'reasonCategory' => 'Warenlieferung',
|
||||||
|
'linkedOrderId' => $orderId,
|
||||||
|
'note' => "Lagereingang aus Bestellung {$order->orderNumber}",
|
||||||
|
'userId' => $this->user->id,
|
||||||
|
'createBy' => $this->user->id,
|
||||||
|
'create' => time()
|
||||||
|
];
|
||||||
|
|
||||||
|
$movementId = WarehouseMovementModel::create($movementData);
|
||||||
|
$createdMovementIds[] = $movementId;
|
||||||
|
|
||||||
|
// Update warehouse item quantity
|
||||||
|
$warehouseItem->quantity = $quantityAfter;
|
||||||
|
WarehouseItemModel::update((array)$warehouseItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $createdMovementIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getLinkedMovementsAction() {
|
||||||
|
$orderId = $this->request->orderId;
|
||||||
|
if (empty($orderId)) {
|
||||||
|
self::returnJson(['error' => 'Order ID is required']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = WarehouseOrderModel::get($orderId);
|
||||||
|
$linkedMovementIds = $order->linkedMovementIds ? json_decode($order->linkedMovementIds, true) : [];
|
||||||
|
|
||||||
|
$movements = [];
|
||||||
|
foreach ($linkedMovementIds as $movementId) {
|
||||||
|
$movement = WarehouseMovementModel::get($movementId);
|
||||||
|
if ($movement) {
|
||||||
|
$article = $movement->getArticle();
|
||||||
|
$location = $movement->getLocation();
|
||||||
|
$movements[] = [
|
||||||
|
'id' => $movement->id,
|
||||||
|
'movementNumber' => $movement->movementNumber,
|
||||||
|
'quantity' => $movement->quantity,
|
||||||
|
'articleName' => $article ? $article->title : 'Unbekannt',
|
||||||
|
'locationName' => $location ? $location->title : 'Unbekannt',
|
||||||
|
'create' => $movement->create
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::returnJson($movements);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getLocationsAction() {
|
||||||
|
$locations = WarehouseLocationModel::getAll();
|
||||||
|
$result = array_map(function($loc) {
|
||||||
|
return [
|
||||||
|
'value' => $loc->id,
|
||||||
|
'text' => $loc->title
|
||||||
|
];
|
||||||
|
}, $locations);
|
||||||
|
self::returnJson($result);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,8 @@ class WarehouseOrderModel extends TTCrudBaseModel {
|
|||||||
public string $delAddrPLZ;
|
public string $delAddrPLZ;
|
||||||
public int $editor;
|
public int $editor;
|
||||||
public ?string $note;
|
public ?string $note;
|
||||||
|
public ?string $linkedMovementIds = null;
|
||||||
|
public ?string $deliveryNoteFileIds = null;
|
||||||
public string $positions;
|
public string $positions;
|
||||||
public ?int $sendShippingNote;
|
public ?int $sendShippingNote;
|
||||||
public int $create;
|
public int $create;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ class WarehouseShippingNoteController extends TTCrud {
|
|||||||
|
|
||||||
//@formatter:off
|
//@formatter:off
|
||||||
protected array $columns = [
|
protected array $columns = [
|
||||||
['key' => 'id', 'text' => 'LS-Nr.', 'required' => false, 'modal' => false, 'table' => ['class' => 'text-nowrap']],
|
['key' => 'shippingNoteNumber', 'text' => 'LS-Nr.', 'required' => false, 'modal' => false, 'table' => ['class' => 'text-nowrap', 'filter' => 'search']],
|
||||||
['key' => 'note', 'text' => 'Art der Arbeit', 'required' => true],
|
['key' => 'note', 'text' => 'Art der Arbeit', 'required' => true],
|
||||||
['key' => 'billingAddressId', 'text' => 'Rechnungsadresse', 'type' => 'autocomplete', 'table' => false, 'modal' => ['apiUrl' => 'Address/api?do=findAddress', 'items' => '/Address/Api?do=findAddress', 'type' => 'autocomplete']],
|
['key' => 'billingAddressId', 'text' => 'Rechnungsadresse', 'type' => 'autocomplete', 'table' => false, 'modal' => ['apiUrl' => 'Address/api?do=findAddress', 'items' => '/Address/Api?do=findAddress', 'type' => 'autocomplete']],
|
||||||
['key' => 'status', 'text' => 'Status', 'required' => true, 'table' => ['filter' => 'iconSelect'], 'modal' => ['type' => 'iconSelect', 'items' => [
|
['key' => 'status', 'text' => 'Status', 'required' => true, 'table' => ['filter' => 'iconSelect'], 'modal' => ['type' => 'iconSelect', 'items' => [
|
||||||
@@ -56,6 +56,7 @@ class WarehouseShippingNoteController extends TTCrud {
|
|||||||
]);
|
]);
|
||||||
$this->postData['positions'] = json_encode($this->postData['positions']);
|
$this->postData['positions'] = json_encode($this->postData['positions']);
|
||||||
if (isset($this->postData['metadata'])) $this->postData['metadata'] = json_encode($this->postData['metadata']);
|
if (isset($this->postData['metadata'])) $this->postData['metadata'] = json_encode($this->postData['metadata']);
|
||||||
|
$this->postData['shippingNoteNumber'] = WarehouseShippingNoteModel::generateShippingNoteNumber();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,10 +487,6 @@ class WarehouseShippingNoteController extends TTCrud {
|
|||||||
"bank_bank" => TT_INVOICE_BANK_BANK,
|
"bank_bank" => TT_INVOICE_BANK_BANK,
|
||||||
"bank_owner" => TT_INVOICE_BANK_OWNER];
|
"bank_owner" => TT_INVOICE_BANK_OWNER];
|
||||||
|
|
||||||
// Replace placeholders in header
|
|
||||||
// create shipping note in this format LS2024-X0001
|
|
||||||
// pad number on the left side with zeros
|
|
||||||
$shippingNoteNumber = "LS" . date("Y", $shippingNote->create) . "-" . str_pad($shippingNote->id, 4, "0", STR_PAD_LEFT);
|
|
||||||
$headerHtml = file_get_contents(BASEDIR . "/Layout/default/WarehouseShippingNote/PDF_HEADER.html");
|
$headerHtml = file_get_contents(BASEDIR . "/Layout/default/WarehouseShippingNote/PDF_HEADER.html");
|
||||||
$headerHtml = str_replace("{{ basedir }}", BASEDIR, $headerHtml);
|
$headerHtml = str_replace("{{ basedir }}", BASEDIR, $headerHtml);
|
||||||
$headerHtml = str_replace("{{ addressLine_1 }}", $shippingNote->deliveryAddressName, $headerHtml);
|
$headerHtml = str_replace("{{ addressLine_1 }}", $shippingNote->deliveryAddressName, $headerHtml);
|
||||||
@@ -504,7 +501,7 @@ class WarehouseShippingNoteController extends TTCrud {
|
|||||||
$headerHtml = str_replace("{{ billingAddressLine_4 }}", "", $headerHtml);
|
$headerHtml = str_replace("{{ billingAddressLine_4 }}", "", $headerHtml);
|
||||||
$headerHtml = str_replace("{{ billingAddressLine_5 }}", "", $headerHtml);
|
$headerHtml = str_replace("{{ billingAddressLine_5 }}", "", $headerHtml);
|
||||||
$headerHtml = str_replace("{{ customerNumber }}", !isset($address) ? '' : $address->customer_number, $headerHtml);
|
$headerHtml = str_replace("{{ customerNumber }}", !isset($address) ? '' : $address->customer_number, $headerHtml);
|
||||||
$headerHtml = str_replace("{{ shippingNoteNumber }}", $shippingNoteNumber, $headerHtml);
|
$headerHtml = str_replace("{{ shippingNoteNumber }}", $shippingNote->shippingNoteNumber, $headerHtml);
|
||||||
$headerHtml = str_replace("{{ shippingNoteDate }}", date("d.m.Y", $shippingNote->create), $headerHtml);
|
$headerHtml = str_replace("{{ shippingNoteDate }}", date("d.m.Y", $shippingNote->create), $headerHtml);
|
||||||
|
|
||||||
$headerFile = BASEDIR . "/var/temp/shipping-note_header-" . date("U") . "-" . rand(1000, 9999) . ".html";
|
$headerFile = BASEDIR . "/var/temp/shipping-note_header-" . date("U") . "-" . rand(1000, 9999) . ".html";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
class WarehouseShippingNoteModel extends TTCrudBaseModel {
|
class WarehouseShippingNoteModel extends TTCrudBaseModel {
|
||||||
public int $id;
|
public int $id;
|
||||||
|
public ?string $shippingNoteNumber = null;
|
||||||
public ?int $billingAddressId;
|
public ?int $billingAddressId;
|
||||||
public ?string $type;
|
public ?string $type;
|
||||||
public ?string $metadata;
|
public ?string $metadata;
|
||||||
@@ -21,4 +22,23 @@ class WarehouseShippingNoteModel extends TTCrudBaseModel {
|
|||||||
public ?int $eShopOrderId;
|
public ?int $eShopOrderId;
|
||||||
public ?int $create;
|
public ?int $create;
|
||||||
public ?int $createBy;
|
public ?int $createBy;
|
||||||
|
|
||||||
|
public static function generateShippingNoteNumber(): string {
|
||||||
|
$year = date('Y');
|
||||||
|
$prefix = "LS{$year}-X";
|
||||||
|
|
||||||
|
$db = FronkDB::singleton();
|
||||||
|
$result = $db->query("SELECT shippingNoteNumber FROM WarehouseShippingNote
|
||||||
|
WHERE shippingNoteNumber LIKE '{$prefix}%'
|
||||||
|
ORDER BY shippingNoteNumber DESC LIMIT 1");
|
||||||
|
|
||||||
|
if ($row = $result->fetch_assoc()) {
|
||||||
|
$lastNumber = intval(substr($row['shippingNoteNumber'], -4));
|
||||||
|
$nextNumber = $lastNumber + 1;
|
||||||
|
} else {
|
||||||
|
$nextNumber = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $prefix . str_pad((string)$nextNumber, 4, '0', STR_PAD_LEFT);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
50
db/migrations/20260117120000_add_shipping_note_number.php
Normal file
50
db/migrations/20260117120000_add_shipping_note_number.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phinx\Migration\AbstractMigration;
|
||||||
|
|
||||||
|
final class AddShippingNoteNumber extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if ($this->getEnvironment() == "thetool") {
|
||||||
|
$table = $this->table('WarehouseShippingNote');
|
||||||
|
$table
|
||||||
|
->addColumn('shippingNoteNumber', 'string', ['limit' => 20, 'null' => true, 'after' => 'id'])
|
||||||
|
->addIndex(['shippingNoteNumber'], ['unique' => true])
|
||||||
|
->update();
|
||||||
|
|
||||||
|
// Get all shipping notes ordered by create timestamp to assign numbers in chronological order
|
||||||
|
$rows = $this->fetchAll(
|
||||||
|
"SELECT id, YEAR(FROM_UNIXTIME(`create`)) as year
|
||||||
|
FROM WarehouseShippingNote
|
||||||
|
ORDER BY `create` ASC, id ASC"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group by year and assign sequential numbers
|
||||||
|
$yearCounters = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$year = $row['year'];
|
||||||
|
if (!isset($yearCounters[$year])) {
|
||||||
|
$yearCounters[$year] = 0;
|
||||||
|
}
|
||||||
|
$yearCounters[$year]++;
|
||||||
|
|
||||||
|
$shippingNoteNumber = 'LS' . $year . '-X' . str_pad((string)$yearCounters[$year], 4, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
|
$this->execute(
|
||||||
|
"UPDATE WarehouseShippingNote SET shippingNoteNumber = '{$shippingNoteNumber}' WHERE id = {$row['id']}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if ($this->getEnvironment() == "thetool") {
|
||||||
|
$table = $this->table('WarehouseShippingNote');
|
||||||
|
$table->removeColumn('shippingNoteNumber')->update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
db/migrations/20260117150000_add_order_movement_linking.php
Normal file
43
db/migrations/20260117150000_add_order_movement_linking.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phinx\Migration\AbstractMigration;
|
||||||
|
|
||||||
|
final class AddOrderMovementLinking extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if ($this->getEnvironment() == "thetool") {
|
||||||
|
// Add columns to WarehouseOrder for linking to movements and delivery note files
|
||||||
|
$orderTable = $this->table('WarehouseOrder');
|
||||||
|
$orderTable
|
||||||
|
->addColumn('linkedMovementIds', 'text', ['null' => true, 'after' => 'note'])
|
||||||
|
->addColumn('deliveryNoteFileIds', 'text', ['null' => true, 'after' => 'linkedMovementIds'])
|
||||||
|
->update();
|
||||||
|
|
||||||
|
// Add column to WarehouseMovement for linking back to orders
|
||||||
|
$movementTable = $this->table('WarehouseMovement');
|
||||||
|
$movementTable
|
||||||
|
->addColumn('linkedOrderId', 'integer', ['null' => true, 'signed' => false, 'after' => 'note'])
|
||||||
|
->addIndex(['linkedOrderId'], ['name' => 'idx_linkedOrderId'])
|
||||||
|
->update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if ($this->getEnvironment() == "thetool") {
|
||||||
|
$orderTable = $this->table('WarehouseOrder');
|
||||||
|
$orderTable
|
||||||
|
->removeColumn('linkedMovementIds')
|
||||||
|
->removeColumn('deliveryNoteFileIds')
|
||||||
|
->update();
|
||||||
|
|
||||||
|
$movementTable = $this->table('WarehouseMovement');
|
||||||
|
$movementTable
|
||||||
|
->removeIndex(['linkedOrderId'])
|
||||||
|
->removeColumn('linkedOrderId')
|
||||||
|
->update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -180,3 +180,411 @@ input:checked + .ios-switch-slider:before {
|
|||||||
input:disabled + .ios-switch-slider {
|
input:disabled + .ios-switch-slider {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== ORDER DETAIL REDESIGN ===== */
|
||||||
|
|
||||||
|
.order-detail-container {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-number {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-meta {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-meta span {
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-new { background: #e3f2fd; color: #1565c0; }
|
||||||
|
.status-accepted { background: #e8f5e9; color: #2e7d32; }
|
||||||
|
.status-ordered { background: #fff3e0; color: #ef6c00; }
|
||||||
|
.status-sent { background: #fce4ec; color: #c2185b; }
|
||||||
|
.status-partiallyDelivered { background: #fff8e1; color: #f9a825; }
|
||||||
|
.status-fullyDelivered { background: #e8f5e9; color: #2e7d32; }
|
||||||
|
.status-cancelled { background: #ffebee; color: #c62828; }
|
||||||
|
|
||||||
|
/* Status Form */
|
||||||
|
.status-form-container {
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid #e3f2fd;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-form-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-form-header > div {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #6c757d;
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-table td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-table input[type="number"] {
|
||||||
|
width: 80px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-table input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-item {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section Titles */
|
||||||
|
.section-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: #6c757d;
|
||||||
|
margin: 24px 0 12px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title i {
|
||||||
|
color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Positions Table */
|
||||||
|
.positions-container {
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positions-table {
|
||||||
|
display: table !important;
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positions-table thead {
|
||||||
|
display: table-header-group !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positions-table tbody {
|
||||||
|
display: table-row-group !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positions-table tfoot {
|
||||||
|
display: table-footer-group !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positions-table tr {
|
||||||
|
display: table-row !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positions-table th,
|
||||||
|
.positions-table td {
|
||||||
|
display: table-cell !important;
|
||||||
|
vertical-align: middle;
|
||||||
|
position: static !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positions-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 14px 20px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
color: #495057;
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
position: static !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right-align numeric columns (Menge, Einzelpreis, Summe) */
|
||||||
|
.positions-table th:nth-child(2),
|
||||||
|
.positions-table th:nth-child(3),
|
||||||
|
.positions-table th:nth-child(4),
|
||||||
|
.positions-table td:nth-child(2),
|
||||||
|
.positions-table td:nth-child(3),
|
||||||
|
.positions-table td:nth-child(4) {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positions-table td {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positions-table tbody td:first-child {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positions-table tbody tr:nth-child(even) {
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positions-table tbody tr:hover {
|
||||||
|
background: #f0f4f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Total row in tfoot */
|
||||||
|
.positions-table tfoot .total-row {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positions-table tfoot .total-row td {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-bottom: none;
|
||||||
|
border-top: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positions-table tfoot .total-row td:first-child {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Movements Table */
|
||||||
|
.movements-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movements-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movements-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movements-table a {
|
||||||
|
color: #1976d2;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movements-table a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movement-qty {
|
||||||
|
color: #2e7d32;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline */
|
||||||
|
.timeline-container {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 24px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 3px;
|
||||||
|
top: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
width: 2px;
|
||||||
|
background: #dee2e6;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-marker {
|
||||||
|
position: absolute;
|
||||||
|
left: -21px;
|
||||||
|
top: 4px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid #1976d2;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item.is-first .timeline-marker {
|
||||||
|
background: #1976d2;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
left: -22px;
|
||||||
|
top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-date {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6c757d;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-author {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #1976d2;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-body {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-files {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-files a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #1565c0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-files a:hover {
|
||||||
|
background: #bbdefb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,20 +11,32 @@ Vue.component('change-status-modal', {
|
|||||||
note: '',
|
note: '',
|
||||||
file: null,
|
file: null,
|
||||||
uploadedFiles: [],
|
uploadedFiles: [],
|
||||||
|
deliveryNoteFiles: [],
|
||||||
sendEmail: false,
|
sendEmail: false,
|
||||||
sendEmailViewedPDF: false,
|
sendEmailViewedPDF: false,
|
||||||
sendEmailMail: '',
|
sendEmailMail: '',
|
||||||
submitLoading: false,
|
submitLoading: false,
|
||||||
deliveredPositions: {} // To track delivery details for each position
|
deliveredPositions: {}, // To track delivery details for each position
|
||||||
|
warehouseLocations: [],
|
||||||
|
selectedLocationId: null
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOrder/getById`, {params: {id: this.orderId}});
|
const [orderResponse, locationsResponse] = await Promise.all([
|
||||||
if (response.data.status === 'cancelled') {
|
axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOrder/getById`, {params: {id: this.orderId}}),
|
||||||
|
axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOrder/getLocations`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (orderResponse.data.status === 'cancelled') {
|
||||||
this.$emit('close');
|
this.$emit('close');
|
||||||
window.notify('error', 'Bestellung wurde storniert');
|
window.notify('error', 'Bestellung wurde storniert');
|
||||||
}
|
}
|
||||||
this.order = response.data;
|
this.order = orderResponse.data;
|
||||||
|
this.warehouseLocations = locationsResponse.data;
|
||||||
|
|
||||||
|
// Set default location to "K1 Fladnitz 150" if available
|
||||||
|
const defaultLocation = this.warehouseLocations.find(loc => loc.text === 'K1 Fladnitz 150');
|
||||||
|
this.selectedLocationId = defaultLocation ? defaultLocation.value : (this.warehouseLocations[0]?.value || null);
|
||||||
|
|
||||||
// Initialize deliveredPositions after fetching the order
|
// Initialize deliveredPositions after fetching the order
|
||||||
if (this.order && this.order.positions) {
|
if (this.order && this.order.positions) {
|
||||||
@@ -40,6 +52,10 @@ Vue.component('change-status-modal', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
movementPreviewCount() {
|
||||||
|
if (!this.deliveredPositions) return 0;
|
||||||
|
return Object.values(this.deliveredPositions).filter(p => p.amount > 0).length;
|
||||||
|
},
|
||||||
availableStatuses() {
|
availableStatuses() {
|
||||||
// This computed property remains unchanged
|
// This computed property remains unchanged
|
||||||
switch (this.order.status) {
|
switch (this.order.status) {
|
||||||
@@ -86,8 +102,7 @@ Vue.component('change-status-modal', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async handleFileUpload(event) {
|
async handleFileUpload(event, isDeliveryNote = false) {
|
||||||
// This method remains unchanged
|
|
||||||
const files = event.target.files;
|
const files = event.target.files;
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
|
|
||||||
@@ -104,16 +119,21 @@ Vue.component('change-status-modal', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
this.uploadedFiles.push({
|
const fileEntry = {
|
||||||
id: response.data.fileId,
|
id: response.data.fileId,
|
||||||
name: file.name
|
name: file.name
|
||||||
});
|
};
|
||||||
window.notify('success', `File "${file.name}" uploaded successfully`);
|
if (isDeliveryNote) {
|
||||||
|
this.deliveryNoteFiles.push(fileEntry);
|
||||||
|
} else {
|
||||||
|
this.uploadedFiles.push(fileEntry);
|
||||||
|
}
|
||||||
|
window.notify('success', `Datei "${file.name}" erfolgreich hochgeladen`);
|
||||||
} else {
|
} else {
|
||||||
window.notify('error', `File "${file.name}" upload failed: ${response.data.error || 'Unknown error'}`);
|
window.notify('error', `Datei "${file.name}" Upload fehlgeschlagen: ${response.data.error || 'Unbekannter Fehler'}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.notify('error', `Error uploading file "${file.name}"`);
|
window.notify('error', `Fehler beim Hochladen von "${file.name}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
@@ -121,6 +141,9 @@ Vue.component('change-status-modal', {
|
|||||||
removeFile(index) {
|
removeFile(index) {
|
||||||
this.uploadedFiles.splice(index, 1)
|
this.uploadedFiles.splice(index, 1)
|
||||||
},
|
},
|
||||||
|
removeDeliveryNoteFile(index) {
|
||||||
|
this.deliveryNoteFiles.splice(index, 1)
|
||||||
|
},
|
||||||
async submit() {
|
async submit() {
|
||||||
this.submitLoading = true;
|
this.submitLoading = true;
|
||||||
if (this.newStatus === 'accepted' && this.sendEmail && !this.sendEmailMail) {
|
if (this.newStatus === 'accepted' && this.sendEmail && !this.sendEmailMail) {
|
||||||
@@ -139,6 +162,7 @@ Vue.component('change-status-modal', {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fileIds = this.uploadedFiles.map(file => file.id);
|
const fileIds = this.uploadedFiles.map(file => file.id);
|
||||||
|
const deliveryNoteFileIds = this.deliveryNoteFiles.map(file => file.id);
|
||||||
|
|
||||||
// Prepare delivery data if the status is related to delivery
|
// Prepare delivery data if the status is related to delivery
|
||||||
let deliveryData = null;
|
let deliveryData = null;
|
||||||
@@ -151,7 +175,9 @@ Vue.component('change-status-modal', {
|
|||||||
status: this.newStatus,
|
status: this.newStatus,
|
||||||
note: this.note,
|
note: this.note,
|
||||||
fileIds: JSON.stringify(fileIds),
|
fileIds: JSON.stringify(fileIds),
|
||||||
deliveryData: deliveryData // Send the new delivery data to the backend
|
deliveryData: deliveryData, // Send the new delivery data to the backend
|
||||||
|
locationId: this.selectedLocationId,
|
||||||
|
deliveryNoteFileIds: deliveryNoteFileIds
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
@@ -230,6 +256,12 @@ Vue.component('change-status-modal', {
|
|||||||
<tt-textarea label="Bemerkung" v-model="note" sm/>
|
<tt-textarea label="Bemerkung" v-model="note" sm/>
|
||||||
|
|
||||||
<div v-if="newStatus === 'partiallyDelivered' || newStatus === 'fullyDelivered'">
|
<div v-if="newStatus === 'partiallyDelivered' || newStatus === 'fullyDelivered'">
|
||||||
|
<h4 class="mt-3">Lagerstandort</h4>
|
||||||
|
<tt-select label="Lagerstandort für Einbuchung"
|
||||||
|
v-model="selectedLocationId"
|
||||||
|
:options="warehouseLocations"
|
||||||
|
sm row/>
|
||||||
|
|
||||||
<h4 class="mt-3">Positionen Lieferung erfassen</h4>
|
<h4 class="mt-3">Positionen Lieferung erfassen</h4>
|
||||||
<div style="display: grid; grid-template-columns: 2fr 1fr 1fr 2fr 1fr; grid-gap: 10px; font-weight: bold; margin-bottom: 10px; padding-bottom: 5px; border-bottom: 1px solid #ccc;">
|
<div style="display: grid; grid-template-columns: 2fr 1fr 1fr 2fr 1fr; grid-gap: 10px; font-weight: bold; margin-bottom: 10px; padding-bottom: 5px; border-bottom: 1px solid #ccc;">
|
||||||
<div>Artikel</div>
|
<div>Artikel</div>
|
||||||
@@ -270,6 +302,32 @@ Vue.component('change-status-modal', {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<div class="alert alert-info mt-3" v-if="movementPreviewCount > 0">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
Es werden <strong>{{ movementPreviewCount }}</strong> Lagerbewegung(en) erstellt.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="mt-3">Lieferschein Foto</h4>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Lieferschein hochladen</label>
|
||||||
|
<input type="file" class="form-control" @change="handleFileUpload($event, true)" multiple accept="image/*,.pdf"/>
|
||||||
|
</div>
|
||||||
|
<div v-if="deliveryNoteFiles.length" class="upload-success-alert mb-3">
|
||||||
|
<div class="alert-header">
|
||||||
|
<i class="fa fa-check-circle" aria-hidden="true"></i>
|
||||||
|
<span>Lieferschein hochgeladen</span>
|
||||||
|
</div>
|
||||||
|
<ul class="file-list">
|
||||||
|
<li v-for="(file, index) in deliveryNoteFiles" :key="file.id" class="file-item">
|
||||||
|
<i class="fa fa-file" aria-hidden="true"></i>
|
||||||
|
<span class="file-name">{{ file.name }}</span>
|
||||||
|
<button type="button" class="remove-btn" @click="removeDeliveryNoteFile(index)">
|
||||||
|
<i class="fa fa-times" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="newStatus === 'accepted'">
|
<div v-if="newStatus === 'accepted'">
|
||||||
@@ -581,60 +639,399 @@ Vue.component('tt-file', {
|
|||||||
|
|
||||||
Vue.component('warehouse-order-detail', {
|
Vue.component('warehouse-order-detail', {
|
||||||
template: `
|
template: `
|
||||||
<tt-card>
|
<div class="order-detail-container">
|
||||||
<template v-slot:header><h4>Bestellungsdetails für #{{ loading ? 'Laden...' : order.orderNumber }}</h4></template>
|
|
||||||
<template v-if="loading">
|
<template v-if="loading">
|
||||||
<div class="d-flex justify-content-center align-items-center">
|
<div class="loading-spinner">
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status"><span class="sr-only">Loading...</span></div>
|
<div class="spinner-border text-primary" role="status"><span class="sr-only">Laden...</span></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
|
||||||
<h3>Positionen</h3>
|
|
||||||
<div class="grid-container header">
|
|
||||||
<div v-for="header in ['Artikel', 'Menge', 'Preis', 'Lieferant', 'Verwendung', 'Summe']"><strong>{{ header }}</strong></div>
|
|
||||||
</div>
|
|
||||||
<div class="grid-container" v-for="p in order.positions">
|
|
||||||
<div>{{ p.articleName }}</div>
|
|
||||||
<div>{{ p.amount }}</div>
|
|
||||||
<div>{{ p.buyPrice }}</div>
|
|
||||||
<div>{{ p.distributorName }}</div>
|
|
||||||
<div>{{ p.verwendung }}</div>
|
|
||||||
<div>{{ p.amount * p.buyPrice }}</div>
|
|
||||||
</div>
|
|
||||||
<template v-if="orderLog?.length > 0">
|
|
||||||
<hr>
|
|
||||||
<h3>Log</h3>
|
|
||||||
<div v-for="log in orderLog">
|
|
||||||
{{ formatDate(log.create) }} ({{ getUserName(log.createBy) }}) | {{ log.message }}
|
|
||||||
<template v-if="log.fileIds">
|
|
||||||
<div v-for="file in JSON.parse(log.fileIds)">
|
|
||||||
<tt-file :id="file"/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- HEADER -->
|
||||||
|
<div class="order-header">
|
||||||
|
<div class="order-info">
|
||||||
|
<span class="order-number">#{{ order.orderNumber }}</span>
|
||||||
|
<div class="order-meta">
|
||||||
|
<span><i class="fas fa-truck"></i> {{ order.distributorName || 'Kein Lieferant' }}</span>
|
||||||
|
<span><i class="fas fa-box"></i> {{ order.positions?.length || 0 }} Positionen</span>
|
||||||
|
<span><i class="fas fa-euro-sign"></i> {{ formatCurrency(grandTotal) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="order-actions">
|
||||||
|
<span :class="['status-badge', 'status-' + order.status]">{{ statusLabel }}</span>
|
||||||
|
<button v-if="!showStatusForm && order.status !== 'cancelled' && order.status !== 'fullyDelivered'"
|
||||||
|
@click="toggleStatusForm"
|
||||||
|
class="btn btn-primary btn-sm">
|
||||||
|
<i class="fas fa-edit"></i> Status ändern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- INLINE STATUS FORM -->
|
||||||
|
<div v-if="showStatusForm" class="status-form-container">
|
||||||
|
<div class="status-form-header">
|
||||||
|
<div>
|
||||||
|
<label class="form-label"><strong>Neuer Status</strong></label>
|
||||||
|
<select v-model="newStatus" class="form-control form-control-sm">
|
||||||
|
<option v-for="s in availableStatuses" :value="s.value">{{ s.text }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-if="isDeliveryStatus">
|
||||||
|
<label class="form-label"><strong>Lagerstandort</strong></label>
|
||||||
|
<select v-model="selectedLocationId" class="form-control form-control-sm">
|
||||||
|
<option v-for="loc in warehouseLocations" :value="loc.value">{{ loc.text }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="isDeliveryStatus">
|
||||||
|
<table class="delivery-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Artikel</th>
|
||||||
|
<th style="width: 80px;">Bestellt</th>
|
||||||
|
<th style="width: 100px;">Geliefert</th>
|
||||||
|
<th>Grund für Abweichung</th>
|
||||||
|
<th style="width: 80px; text-align: center;">Rest stornieren</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(pos, index) in order.positions" :key="index">
|
||||||
|
<td>{{ pos.articleName }}</td>
|
||||||
|
<td class="text-center">{{ pos.amount }}</td>
|
||||||
|
<td>
|
||||||
|
<input type="number" v-model.number="deliveredPositions[index].amount" :max="pos.amount" min="0"/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text"
|
||||||
|
v-if="deliveredPositions[index].amount < pos.amount"
|
||||||
|
v-model="deliveredPositions[index].reason"
|
||||||
|
placeholder="z.B. Lieferschaden"/>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<input type="checkbox"
|
||||||
|
v-if="deliveredPositions[index].amount < pos.amount"
|
||||||
|
v-model="deliveredPositions[index].cancelRest"/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div v-if="movementPreviewCount > 0" class="alert alert-info" style="margin: 12px 0;">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
Es werden <strong>{{ movementPreviewCount }}</strong> Lagerbewegung(en) erstellt.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="file-upload-row">
|
||||||
|
<div class="file-upload-item">
|
||||||
|
<label class="form-label"><i class="fas fa-file"></i> Datei anhängen</label>
|
||||||
|
<input type="file" class="form-control form-control-sm" @change="handleFileUpload($event, false)" multiple/>
|
||||||
|
<div v-if="uploadedFiles.length" class="mt-2">
|
||||||
|
<span v-for="(f, i) in uploadedFiles" :key="f.id" class="badge bg-success me-1">
|
||||||
|
{{ f.name }} <i class="fas fa-times" style="cursor:pointer" @click="uploadedFiles.splice(i, 1)"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isDeliveryStatus" class="file-upload-item">
|
||||||
|
<label class="form-label"><i class="fas fa-camera"></i> Lieferschein Foto</label>
|
||||||
|
<input type="file" class="form-control form-control-sm" @change="handleFileUpload($event, true)" accept="image/*,.pdf"/>
|
||||||
|
<div v-if="deliveryNoteFiles.length" class="mt-2">
|
||||||
|
<span v-for="(f, i) in deliveryNoteFiles" :key="f.id" class="badge bg-primary me-1">
|
||||||
|
{{ f.name }} <i class="fas fa-times" style="cursor:pointer" @click="deliveryNoteFiles.splice(i, 1)"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 12px;">
|
||||||
|
<label class="form-label"><i class="fas fa-comment"></i> Bemerkung</label>
|
||||||
|
<textarea v-model="note" class="form-control form-control-sm" rows="2" placeholder="Optionale Bemerkung..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-form-actions">
|
||||||
|
<button @click="cancelStatusChange" class="btn btn-outline-secondary btn-sm">Abbrechen</button>
|
||||||
|
<button @click="submitStatusChange" :disabled="isSubmitting" class="btn btn-success btn-sm">
|
||||||
|
<i class="fas fa-check"></i> {{ isSubmitting ? 'Wird gespeichert...' : 'Speichern' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- POSITIONEN -->
|
||||||
|
<div class="section-title"><i class="fas fa-list"></i> Positionen</div>
|
||||||
|
<div class="positions-container">
|
||||||
|
<table class="positions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Artikel</th>
|
||||||
|
<th>Menge</th>
|
||||||
|
<th>Einzelpreis</th>
|
||||||
|
<th>Summe</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="p in order.positions" :key="p.article">
|
||||||
|
<td>{{ p.articleName }}</td>
|
||||||
|
<td>{{ p.amount }} Stk</td>
|
||||||
|
<td>{{ formatCurrency(p.buyPrice) }}</td>
|
||||||
|
<td>{{ formatCurrency(p.amount * p.buyPrice) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td>Gesamtsumme</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td>{{ formatCurrency(grandTotal) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LAGERBEWEGUNGEN -->
|
||||||
|
<template v-if="linkedMovements?.length > 0">
|
||||||
|
<div class="section-title"><i class="fas fa-warehouse"></i> Lagerbewegungen</div>
|
||||||
|
<table class="movements-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nummer</th>
|
||||||
|
<th>Artikel</th>
|
||||||
|
<th>Menge</th>
|
||||||
|
<th>Lagerort</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="m in linkedMovements" :key="m.id">
|
||||||
|
<td><a :href="window.TT_CONFIG.BASE_PATH + '/WarehouseMovement?showId=' + m.id" target="_blank">{{ m.movementNumber }}</a></td>
|
||||||
|
<td>{{ m.articleName }}</td>
|
||||||
|
<td class="movement-qty">+{{ m.quantity }}</td>
|
||||||
|
<td>{{ m.locationName }}</td>
|
||||||
|
<td>{{ formatDate(m.create) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- AKTIVITÄT / TIMELINE -->
|
||||||
|
<template v-if="sortedLog?.length > 0">
|
||||||
|
<div class="section-title"><i class="fas fa-history"></i> Aktivität</div>
|
||||||
|
<div class="timeline-container">
|
||||||
|
<div class="timeline">
|
||||||
|
<div v-for="(log, index) in sortedLog" :key="log.id || index" class="timeline-item" :class="{ 'is-first': index === 0 }">
|
||||||
|
<div class="timeline-marker"></div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<div class="timeline-header">
|
||||||
|
<span class="timeline-date">{{ formatDate(log.create) }}</span>
|
||||||
|
<span class="timeline-author">{{ getUserName(log.createBy) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-body">{{ log.message }}</div>
|
||||||
|
<div v-if="log.fileIds && JSON.parse(log.fileIds).length > 0" class="timeline-files">
|
||||||
|
<a v-for="fileId in JSON.parse(log.fileIds)" :key="fileId" :href="'/File/download?id=' + fileId" target="_blank">
|
||||||
|
<i class="fas fa-paperclip"></i> Anhang
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<hr>
|
|
||||||
<h3>Lieferadresse</h3>
|
|
||||||
<div v-for="field in ['delAddrName', 'delAddrEMail', 'delAddrLine']">{{ order[field] }}</div>
|
|
||||||
<div>{{ order.delAddrPLZ }} {{ order.delAddrCity }}</div>
|
|
||||||
</template>
|
</template>
|
||||||
</tt-card>
|
</div>
|
||||||
`,
|
`,
|
||||||
props: ['id'],
|
props: ['id'],
|
||||||
data: () => ({order: {}, orderLog: null, loading: true}),
|
data() {
|
||||||
|
return {
|
||||||
|
order: {},
|
||||||
|
orderLog: null,
|
||||||
|
linkedMovements: [],
|
||||||
|
loading: true,
|
||||||
|
window: window,
|
||||||
|
// Status form state
|
||||||
|
showStatusForm: false,
|
||||||
|
newStatus: 'noChanges',
|
||||||
|
selectedLocationId: null,
|
||||||
|
warehouseLocations: [],
|
||||||
|
deliveredPositions: {},
|
||||||
|
uploadedFiles: [],
|
||||||
|
deliveryNoteFiles: [],
|
||||||
|
note: '',
|
||||||
|
isSubmitting: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
grandTotal() {
|
||||||
|
return this.order.positions?.reduce((sum, p) => sum + (p.amount * p.buyPrice), 0) || 0;
|
||||||
|
},
|
||||||
|
statusLabel() {
|
||||||
|
const labels = {
|
||||||
|
new: 'Neu',
|
||||||
|
accepted: 'Akzeptiert',
|
||||||
|
ordered: 'Bestellt',
|
||||||
|
sent: 'Versendet',
|
||||||
|
partiallyDelivered: 'Teilweise geliefert',
|
||||||
|
fullyDelivered: 'Geliefert',
|
||||||
|
cancelled: 'Storniert'
|
||||||
|
};
|
||||||
|
return labels[this.order.status] || this.order.status;
|
||||||
|
},
|
||||||
|
availableStatuses() {
|
||||||
|
const statusMap = {
|
||||||
|
new: [
|
||||||
|
{value: 'noChanges', text: 'Keine Änderungen'},
|
||||||
|
{value: 'accepted', text: 'Akzeptiert'},
|
||||||
|
{value: 'ordered', text: 'Bestellt'},
|
||||||
|
{value: 'cancelled', text: 'Storniert'}
|
||||||
|
],
|
||||||
|
accepted: [
|
||||||
|
{value: 'noChanges', text: 'Keine Änderungen'},
|
||||||
|
{value: 'ordered', text: 'Bestellt'},
|
||||||
|
{value: 'cancelled', text: 'Storniert'}
|
||||||
|
],
|
||||||
|
ordered: [
|
||||||
|
{value: 'noChanges', text: 'Keine Änderungen'},
|
||||||
|
{value: 'sent', text: 'Versendet'},
|
||||||
|
{value: 'fullyDelivered', text: 'Geliefert'},
|
||||||
|
{value: 'partiallyDelivered', text: 'Teilweise geliefert'},
|
||||||
|
{value: 'cancelled', text: 'Storniert'}
|
||||||
|
],
|
||||||
|
sent: [
|
||||||
|
{value: 'noChanges', text: 'Keine Änderungen'},
|
||||||
|
{value: 'partiallyDelivered', text: 'Teilweise geliefert'},
|
||||||
|
{value: 'fullyDelivered', text: 'Geliefert'},
|
||||||
|
{value: 'cancelled', text: 'Storniert'}
|
||||||
|
],
|
||||||
|
partiallyDelivered: [
|
||||||
|
{value: 'noChanges', text: 'Keine Änderungen'},
|
||||||
|
{value: 'fullyDelivered', text: 'Geliefert'},
|
||||||
|
{value: 'cancelled', text: 'Storniert'}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
return statusMap[this.order.status] || [{value: 'noChanges', text: 'Keine Änderungen'}];
|
||||||
|
},
|
||||||
|
isDeliveryStatus() {
|
||||||
|
return this.newStatus === 'partiallyDelivered' || this.newStatus === 'fullyDelivered';
|
||||||
|
},
|
||||||
|
movementPreviewCount() {
|
||||||
|
if (!this.deliveredPositions) return 0;
|
||||||
|
return Object.values(this.deliveredPositions).filter(p => p.amount > 0).length;
|
||||||
|
},
|
||||||
|
sortedLog() {
|
||||||
|
if (!this.orderLog) return [];
|
||||||
|
return [...this.orderLog].sort((a, b) => b.create - a.create);
|
||||||
|
}
|
||||||
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
const [orderResponse, logResponse] = await Promise.all([
|
const [orderResponse, logResponse, movementsResponse, locationsResponse] = await Promise.all([
|
||||||
axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/getById`, {params: {id: this.id}}),
|
axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/getById`, {params: {id: this.id}}),
|
||||||
axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/getLogById`, {params: {id: this.id}})
|
axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/getLogById`, {params: {id: this.id}}),
|
||||||
|
axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/getLinkedMovements`, {params: {orderId: this.id}}),
|
||||||
|
axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/getLocations`)
|
||||||
]);
|
]);
|
||||||
this.order = orderResponse.data;
|
this.order = orderResponse.data;
|
||||||
this.orderLog = logResponse.data;
|
this.orderLog = logResponse.data;
|
||||||
|
this.linkedMovements = movementsResponse.data || [];
|
||||||
|
this.warehouseLocations = locationsResponse.data || [];
|
||||||
|
|
||||||
|
// Set default location
|
||||||
|
const defaultLoc = this.warehouseLocations.find(l => l.text === 'K1 Fladnitz 150');
|
||||||
|
this.selectedLocationId = defaultLoc ? defaultLoc.value : (this.warehouseLocations[0]?.value || null);
|
||||||
|
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
formatDate: date => window.moment(date * 1000).format('DD.MM.YYYY HH:mm'),
|
formatDate: date => window.moment(date * 1000).format('DD.MM.YYYY HH:mm'),
|
||||||
getUserName: id => window.TT_CONFIG.CRUD_CONFIG.columns.find(col => col.key === 'createBy')?.modal.items.find(u => u.value === id)?.text
|
formatCurrency(value) {
|
||||||
|
return new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(value);
|
||||||
|
},
|
||||||
|
getUserName: id => window.TT_CONFIG.CRUD_CONFIG.columns.find(col => col.key === 'createBy')?.modal.items.find(u => u.value === id)?.text || 'Unbekannt',
|
||||||
|
toggleStatusForm() {
|
||||||
|
this.showStatusForm = !this.showStatusForm;
|
||||||
|
if (this.showStatusForm) {
|
||||||
|
this.initDeliveredPositions();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initDeliveredPositions() {
|
||||||
|
this.deliveredPositions = {};
|
||||||
|
if (this.order && this.order.positions) {
|
||||||
|
this.order.positions.forEach((pos, index) => {
|
||||||
|
this.$set(this.deliveredPositions, index, {
|
||||||
|
amount: pos.amount,
|
||||||
|
reason: '',
|
||||||
|
cancelRest: false,
|
||||||
|
articleName: pos.articleName,
|
||||||
|
orderedAmount: pos.amount
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async handleFileUpload(event, isDeliveryNote) {
|
||||||
|
const files = event.target.files;
|
||||||
|
if (!files.length) return;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/uploadFile`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
});
|
||||||
|
if (response.data.success) {
|
||||||
|
const entry = { id: response.data.fileId, name: file.name };
|
||||||
|
if (isDeliveryNote) {
|
||||||
|
this.deliveryNoteFiles.push(entry);
|
||||||
|
} else {
|
||||||
|
this.uploadedFiles.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
window.notify('error', 'Fehler beim Hochladen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event.target.value = '';
|
||||||
|
},
|
||||||
|
cancelStatusChange() {
|
||||||
|
this.showStatusForm = false;
|
||||||
|
this.newStatus = 'noChanges';
|
||||||
|
this.note = '';
|
||||||
|
this.uploadedFiles = [];
|
||||||
|
this.deliveryNoteFiles = [];
|
||||||
|
},
|
||||||
|
async submitStatusChange() {
|
||||||
|
this.isSubmitting = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/createNewLogAction`, {
|
||||||
|
orderId: this.order.id,
|
||||||
|
status: this.newStatus,
|
||||||
|
note: this.note,
|
||||||
|
fileIds: JSON.stringify(this.uploadedFiles.map(f => f.id)),
|
||||||
|
deliveryData: this.isDeliveryStatus ? this.deliveredPositions : null,
|
||||||
|
locationId: this.selectedLocationId,
|
||||||
|
deliveryNoteFileIds: this.deliveryNoteFiles.map(f => f.id)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
window.notify('success', 'Status erfolgreich geändert');
|
||||||
|
this.cancelStatusChange();
|
||||||
|
// Reload data
|
||||||
|
const [orderRes, logRes, movRes] = await Promise.all([
|
||||||
|
axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/getById`, {params: {id: this.id}}),
|
||||||
|
axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/getLogById`, {params: {id: this.id}}),
|
||||||
|
axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/getLinkedMovements`, {params: {orderId: this.id}})
|
||||||
|
]);
|
||||||
|
this.order = orderRes.data;
|
||||||
|
this.orderLog = logRes.data;
|
||||||
|
this.linkedMovements = movRes.data || [];
|
||||||
|
// Emit event to refresh table
|
||||||
|
this.$emit('status-changed');
|
||||||
|
} else {
|
||||||
|
window.notify('error', response.data.error || 'Fehler beim Speichern');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
window.notify('error', 'Netzwerkfehler');
|
||||||
|
}
|
||||||
|
this.isSubmitting = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -642,22 +1039,19 @@ Vue.component('warehouse-order', {
|
|||||||
template: `
|
template: `
|
||||||
<tt-card>
|
<tt-card>
|
||||||
<warehouse-order-modal v-if="orderModalId" :id="orderModalId" @close="closeModal"/>
|
<warehouse-order-modal v-if="orderModalId" :id="orderModalId" @close="closeModal"/>
|
||||||
<change-status-modal v-if="changeStatusModalId" :orderId="changeStatusModalId" @close="closeModal"/>
|
|
||||||
<tt-button text="Bestellung erstellen" @click="orderModalId = 'create'" additional-class="btn-primary"/>
|
<tt-button text="Bestellung erstellen" @click="orderModalId = 'create'" additional-class="btn-primary"/>
|
||||||
<tt-table-crud emit-edit
|
<tt-table-crud emit-edit
|
||||||
@openpdf="openPDF"
|
@openpdf="openPDF"
|
||||||
@changeStatus="changeStatusModalId = $event.id"
|
|
||||||
@edit="orderModalId = $event.id; $refs.table.$refs.table.refreshTable()" ref="table">
|
@edit="orderModalId = $event.id; $refs.table.$refs.table.refreshTable()" ref="table">
|
||||||
<template v-slot:expandedRow="{ row }">
|
<template v-slot:expandedRow="{ row }">
|
||||||
<warehouse-order-detail :id="row.id"/>
|
<warehouse-order-detail :id="row.id" @status-changed="refreshTable"/>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:sum="{ row }">{{ calculateSum(JSON.parse(row["positions"])).toFixed(2) }} €</template>
|
<template v-slot:sum="{ row }">{{ calculateSum(JSON.parse(row["positions"])).toFixed(2) }} €</template>
|
||||||
</tt-table-crud>
|
</tt-table-crud>
|
||||||
</tt-card>
|
</tt-card>
|
||||||
`,
|
`,
|
||||||
data: () => ({
|
data: () => ({
|
||||||
orderModalId: null,
|
orderModalId: null
|
||||||
changeStatusModalId: null
|
|
||||||
}),
|
}),
|
||||||
mounted() {
|
mounted() {
|
||||||
if (JSON.parse(localStorage.getItem('WarehouseOrder_create'))) this.orderModalId = 'create';
|
if (JSON.parse(localStorage.getItem('WarehouseOrder_create'))) this.orderModalId = 'create';
|
||||||
@@ -665,10 +1059,12 @@ Vue.component('warehouse-order', {
|
|||||||
methods: {
|
methods: {
|
||||||
async closeModal() {
|
async closeModal() {
|
||||||
this.orderModalId = null;
|
this.orderModalId = null;
|
||||||
this.changeStatusModalId = null;
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 250));
|
await new Promise(resolve => setTimeout(resolve, 250));
|
||||||
this.$refs.table.$refs.table.refreshTable();
|
this.$refs.table.$refs.table.refreshTable();
|
||||||
},
|
},
|
||||||
|
refreshTable() {
|
||||||
|
this.$refs.table.$refs.table.refreshTable();
|
||||||
|
},
|
||||||
calculateSum: positions => positions.reduce((sum, {amount, buyPrice}) => sum + amount * buyPrice, 0),
|
calculateSum: positions => positions.reduce((sum, {amount, buyPrice}) => sum + amount * buyPrice, 0),
|
||||||
openPDF: order => window.open(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOrder/createPDF?id=${order.id}`)
|
openPDF: order => window.open(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOrder/createPDF?id=${order.id}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,23 @@
|
|||||||
/**
|
|
||||||
* MobileApp PWA - Main Vue Application
|
|
||||||
*
|
|
||||||
* Unified mobile app with module navigation.
|
|
||||||
* Routes: /MobileApp -> Home, /MobileApp/Lager -> Module, /MobileApp/Lager/Inventur -> Submodule
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { authState, checkAuth, login, logout } from '/mobile/shared/auth.js';
|
import { authState, checkAuth, login, logout } from '/mobile/shared/auth.js';
|
||||||
import LoginScreen from '/mobile/components/LoginScreen.js';
|
import LoginScreen from '/mobile/components/LoginScreen.js';
|
||||||
import MainMenu from '/mobile/components/MainMenu.js';
|
import MainMenu from '/mobile/components/MainMenu.js';
|
||||||
import LagerModule from '/mobile/modules/lager/LagerModule.js';
|
import LagerModule from '/mobile/modules/lager/LagerModule.js';
|
||||||
|
import ShippingNoteModule from '/mobile/modules/lager/shippingnote/ShippingNoteModule.js';
|
||||||
|
import WorkorderModule from '/mobile/modules/workorder/WorkorderModule.js';
|
||||||
|
|
||||||
const { createApp, ref, computed, onMounted, onUnmounted, watch } = Vue;
|
const { createApp, ref, computed, onMounted, onUnmounted, watch } = Vue;
|
||||||
|
|
||||||
// Check if running as installed PWA
|
|
||||||
const isPWAInstalled = () => {
|
const isPWAInstalled = () => {
|
||||||
// Check display-mode standalone (Android Chrome, desktop)
|
|
||||||
if (window.matchMedia('(display-mode: standalone)').matches) return true;
|
if (window.matchMedia('(display-mode: standalone)').matches) return true;
|
||||||
// Check iOS Safari standalone mode
|
|
||||||
if (window.navigator.standalone === true) return true;
|
if (window.navigator.standalone === true) return true;
|
||||||
// Check if launched from TWA (Trusted Web Activity)
|
|
||||||
if (document.referrer.includes('android-app://')) return true;
|
if (document.referrer.includes('android-app://')) return true;
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if we should require PWA installation
|
|
||||||
const shouldRequirePWA = () => {
|
const shouldRequirePWA = () => {
|
||||||
const hostname = window.location.hostname;
|
return window.location.hostname === 'thetool.xinon.at';
|
||||||
// Only require PWA on production domain
|
|
||||||
return hostname === 'thetool.xinon.at';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse initial path from config
|
|
||||||
const parseInitialRoute = () => {
|
const parseInitialRoute = () => {
|
||||||
const initialPath = window.TT_CONFIG?.INITIAL_PATH || '/MobileApp';
|
const initialPath = window.TT_CONFIG?.INITIAL_PATH || '/MobileApp';
|
||||||
const parts = initialPath.replace('/MobileApp', '').split('/').filter(Boolean);
|
const parts = initialPath.replace('/MobileApp', '').split('/').filter(Boolean);
|
||||||
@@ -44,34 +31,28 @@ const App = {
|
|||||||
components: {
|
components: {
|
||||||
LoginScreen,
|
LoginScreen,
|
||||||
MainMenu,
|
MainMenu,
|
||||||
LagerModule
|
LagerModule,
|
||||||
|
ShippingNoteModule,
|
||||||
|
WorkorderModule
|
||||||
},
|
},
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
// ==================== STATE ====================
|
|
||||||
const currentView = ref('loading');
|
const currentView = ref('loading');
|
||||||
const user = ref(null);
|
const user = ref(null);
|
||||||
const toast = ref({ show: false, message: '', type: 'success' });
|
const toast = ref({ show: false, message: '', type: 'success' });
|
||||||
const theme = ref('system');
|
const theme = ref('system');
|
||||||
const showSettings = ref(false);
|
const showSettings = ref(false);
|
||||||
|
|
||||||
// Module-specific settings
|
|
||||||
const lagerSimpleMode = ref(false);
|
const lagerSimpleMode = ref(false);
|
||||||
|
|
||||||
// Navigation state
|
|
||||||
const currentModule = ref(null);
|
const currentModule = ref(null);
|
||||||
const currentSubmodule = ref(null);
|
const currentSubmodule = ref(null);
|
||||||
|
const lastWorkflow = ref(null);
|
||||||
// PWA Install state
|
const showContinuePrompt = ref(false);
|
||||||
const showInstallPrompt = ref(false);
|
const showInstallPrompt = ref(false);
|
||||||
const deferredInstallPrompt = ref(null);
|
const deferredInstallPrompt = ref(null);
|
||||||
const isIOS = ref(/iPad|iPhone|iPod/.test(navigator.userAgent));
|
const isIOS = ref(/iPad|iPhone|iPod/.test(navigator.userAgent));
|
||||||
const isAndroid = ref(/Android/.test(navigator.userAgent));
|
const isAndroid = ref(/Android/.test(navigator.userAgent));
|
||||||
|
|
||||||
// Can go back?
|
|
||||||
const canGoBack = computed(() => currentModule.value !== null);
|
const canGoBack = computed(() => currentModule.value !== null);
|
||||||
|
|
||||||
// ==================== THEME ====================
|
|
||||||
const applyTheme = () => {
|
const applyTheme = () => {
|
||||||
const isDark = localStorage.theme === 'dark' ||
|
const isDark = localStorage.theme === 'dark' ||
|
||||||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
@@ -93,33 +74,22 @@ const App = {
|
|||||||
applyTheme();
|
applyTheme();
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==================== PWA INSTALL ====================
|
|
||||||
const handleInstallPrompt = (e) => {
|
const handleInstallPrompt = (e) => {
|
||||||
// Prevent Chrome's default install prompt
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Store the event for later use
|
|
||||||
deferredInstallPrompt.value = e;
|
deferredInstallPrompt.value = e;
|
||||||
};
|
};
|
||||||
|
|
||||||
const triggerInstall = async () => {
|
const triggerInstall = async () => {
|
||||||
if (!deferredInstallPrompt.value) return;
|
if (!deferredInstallPrompt.value) return;
|
||||||
|
|
||||||
// Show the install prompt
|
|
||||||
deferredInstallPrompt.value.prompt();
|
deferredInstallPrompt.value.prompt();
|
||||||
|
|
||||||
// Wait for user response
|
|
||||||
const { outcome } = await deferredInstallPrompt.value.userChoice;
|
const { outcome } = await deferredInstallPrompt.value.userChoice;
|
||||||
|
|
||||||
if (outcome === 'accepted') {
|
if (outcome === 'accepted') {
|
||||||
showInstallPrompt.value = false;
|
showInstallPrompt.value = false;
|
||||||
// Reload to get standalone mode
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
deferredInstallPrompt.value = null;
|
deferredInstallPrompt.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==================== LAGER SETTINGS ====================
|
|
||||||
const loadLagerSettings = () => {
|
const loadLagerSettings = () => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('movement_settings');
|
const saved = localStorage.getItem('movement_settings');
|
||||||
@@ -140,18 +110,48 @@ const App = {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==================== NAVIGATION ====================
|
const saveLastWorkflow = (module, submodule) => {
|
||||||
|
if (module) {
|
||||||
|
const workflow = { module, submodule: submodule || null, timestamp: Date.now() };
|
||||||
|
localStorage.setItem('lastWorkflow', JSON.stringify(workflow));
|
||||||
|
lastWorkflow.value = workflow;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadLastWorkflow = () => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('lastWorkflow');
|
||||||
|
if (saved) {
|
||||||
|
const workflow = JSON.parse(saved);
|
||||||
|
if (Date.now() - workflow.timestamp < 24 * 60 * 60 * 1000) {
|
||||||
|
return workflow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const navigate = (module, submodule = null) => {
|
const navigate = (module, submodule = null) => {
|
||||||
currentModule.value = module;
|
currentModule.value = module;
|
||||||
currentSubmodule.value = submodule;
|
currentSubmodule.value = submodule;
|
||||||
|
showContinuePrompt.value = false;
|
||||||
// Update browser URL
|
saveLastWorkflow(module, submodule);
|
||||||
let path = '/MobileApp';
|
let path = '/MobileApp';
|
||||||
if (module) path += '/' + module;
|
if (module) path += '/' + module;
|
||||||
if (submodule) path += '/' + submodule;
|
if (submodule) path += '/' + submodule;
|
||||||
history.pushState({ module, submodule }, '', path);
|
history.pushState({ module, submodule }, '', path);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const continueLastWorkflow = () => {
|
||||||
|
if (lastWorkflow.value) {
|
||||||
|
navigate(lastWorkflow.value.module, lastWorkflow.value.submodule);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissContinuePrompt = () => {
|
||||||
|
showContinuePrompt.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
const goHome = () => {
|
const goHome = () => {
|
||||||
navigate(null, null);
|
navigate(null, null);
|
||||||
};
|
};
|
||||||
@@ -164,8 +164,7 @@ const App = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle browser back button
|
const handlePopstate = (event) => {
|
||||||
window.addEventListener('popstate', (event) => {
|
|
||||||
if (event.state) {
|
if (event.state) {
|
||||||
currentModule.value = event.state.module;
|
currentModule.value = event.state.module;
|
||||||
currentSubmodule.value = event.state.submodule;
|
currentSubmodule.value = event.state.submodule;
|
||||||
@@ -173,11 +172,9 @@ const App = {
|
|||||||
currentModule.value = null;
|
currentModule.value = null;
|
||||||
currentSubmodule.value = null;
|
currentSubmodule.value = null;
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
// ==================== AUTH ====================
|
|
||||||
const handleLogin = async (credentials) => {
|
const handleLogin = async (credentials) => {
|
||||||
// Handle 2FA success (already verified in LoginScreen)
|
|
||||||
if (credentials._2faSuccess) {
|
if (credentials._2faSuccess) {
|
||||||
user.value = credentials.user;
|
user.value = credentials.user;
|
||||||
currentView.value = 'app';
|
currentView.value = 'app';
|
||||||
@@ -203,19 +200,17 @@ const App = {
|
|||||||
showToast('Abgemeldet', 'success');
|
showToast('Abgemeldet', 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==================== TOAST ====================
|
|
||||||
const showToast = (message, type = 'success') => {
|
const showToast = (message, type = 'success') => {
|
||||||
toast.value = { show: true, message, type };
|
toast.value = { show: true, message, type };
|
||||||
setTimeout(() => {
|
setTimeout(() => { toast.value.show = false; }, 3000);
|
||||||
toast.value.show = false;
|
|
||||||
}, 3000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==================== COMPUTED ====================
|
|
||||||
const currentComponent = computed(() => {
|
const currentComponent = computed(() => {
|
||||||
if (currentView.value !== 'app') return null;
|
if (currentView.value !== 'app') return null;
|
||||||
if (!currentModule.value) return 'MainMenu';
|
if (!currentModule.value) return 'MainMenu';
|
||||||
|
if (currentModule.value.toLowerCase() === 'lieferschein') return 'ShippingNoteModule';
|
||||||
if (currentModule.value.toLowerCase() === 'lager') return 'LagerModule';
|
if (currentModule.value.toLowerCase() === 'lager') return 'LagerModule';
|
||||||
|
if (currentModule.value.toLowerCase() === 'workorder') return 'WorkorderModule';
|
||||||
return 'MainMenu';
|
return 'MainMenu';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -230,51 +225,53 @@ const App = {
|
|||||||
return crumbs;
|
return crumbs;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==================== LIFECYCLE ====================
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
onMounted(async () => {
|
|
||||||
// Initialize theme
|
|
||||||
const savedTheme = localStorage.getItem('theme');
|
|
||||||
if (savedTheme) {
|
|
||||||
theme.value = savedTheme;
|
|
||||||
}
|
|
||||||
applyTheme();
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
|
|
||||||
|
|
||||||
// Load module settings
|
onMounted(async () => {
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
if (savedTheme) theme.value = savedTheme;
|
||||||
|
applyTheme();
|
||||||
|
mediaQuery.addEventListener('change', applyTheme);
|
||||||
|
window.addEventListener('popstate', handlePopstate);
|
||||||
|
window.addEventListener('beforeinstallprompt', handleInstallPrompt);
|
||||||
loadLagerSettings();
|
loadLagerSettings();
|
||||||
|
|
||||||
// Listen for beforeinstallprompt (Android)
|
|
||||||
window.addEventListener('beforeinstallprompt', handleInstallPrompt);
|
|
||||||
|
|
||||||
// Check if PWA is required but not installed
|
|
||||||
if (shouldRequirePWA() && !isPWAInstalled()) {
|
if (shouldRequirePWA() && !isPWAInstalled()) {
|
||||||
showInstallPrompt.value = true;
|
showInstallPrompt.value = true;
|
||||||
currentView.value = 'install';
|
currentView.value = 'install';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check authentication
|
|
||||||
const result = await checkAuth();
|
const result = await checkAuth();
|
||||||
if (result.authenticated) {
|
if (result.authenticated) {
|
||||||
user.value = result.user;
|
user.value = result.user;
|
||||||
currentView.value = 'app';
|
currentView.value = 'app';
|
||||||
|
|
||||||
// Parse initial route
|
|
||||||
const initialRoute = parseInitialRoute();
|
const initialRoute = parseInitialRoute();
|
||||||
currentModule.value = initialRoute.module;
|
currentModule.value = initialRoute.module;
|
||||||
currentSubmodule.value = initialRoute.submodule;
|
currentSubmodule.value = initialRoute.submodule;
|
||||||
|
|
||||||
// Set initial history state
|
|
||||||
history.replaceState(
|
history.replaceState(
|
||||||
{ module: initialRoute.module, submodule: initialRoute.submodule },
|
{ module: initialRoute.module, submodule: initialRoute.submodule },
|
||||||
'',
|
'',
|
||||||
window.location.pathname
|
window.location.pathname
|
||||||
);
|
);
|
||||||
|
if (!initialRoute.module && !initialRoute.submodule) {
|
||||||
|
const saved = loadLastWorkflow();
|
||||||
|
if (saved) {
|
||||||
|
lastWorkflow.value = saved;
|
||||||
|
showContinuePrompt.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
currentView.value = 'login';
|
currentView.value = 'login';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
mediaQuery.removeEventListener('change', applyTheme);
|
||||||
|
window.removeEventListener('popstate', handlePopstate);
|
||||||
|
window.removeEventListener('beforeinstallprompt', handleInstallPrompt);
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentView,
|
currentView,
|
||||||
user,
|
user,
|
||||||
@@ -295,18 +292,20 @@ const App = {
|
|||||||
setTheme,
|
setTheme,
|
||||||
lagerSimpleMode,
|
lagerSimpleMode,
|
||||||
setLagerSimpleMode,
|
setLagerSimpleMode,
|
||||||
// PWA Install
|
|
||||||
showInstallPrompt,
|
showInstallPrompt,
|
||||||
deferredInstallPrompt,
|
deferredInstallPrompt,
|
||||||
isIOS,
|
isIOS,
|
||||||
isAndroid,
|
isAndroid,
|
||||||
triggerInstall,
|
triggerInstall,
|
||||||
|
lastWorkflow,
|
||||||
|
showContinuePrompt,
|
||||||
|
continueLastWorkflow,
|
||||||
|
dismissContinuePrompt,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
template: `
|
template: `
|
||||||
<div class="relative h-full w-full bg-slate-50 dark:bg-slate-900 transition-colors duration-300">
|
<div class="relative h-full w-full bg-slate-50 dark:bg-slate-900 transition-colors duration-300">
|
||||||
<!-- Loading State -->
|
|
||||||
<div v-if="currentView === 'loading'" class="flex items-center justify-center h-full">
|
<div v-if="currentView === 'loading'" class="flex items-center justify-center h-full">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<img src="/assets/images/xinon-full-transparent.png" class="h-12 mx-auto mb-4 dark:hidden">
|
<img src="/assets/images/xinon-full-transparent.png" class="h-12 mx-auto mb-4 dark:hidden">
|
||||||
@@ -315,9 +314,7 @@ const App = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PWA Install Prompt -->
|
|
||||||
<div v-else-if="currentView === 'install'" class="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
|
<div v-else-if="currentView === 'install'" class="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
|
||||||
<!-- Network Background (same as login) -->
|
|
||||||
<div class="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
<div class="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||||
<div class="absolute inset-0 opacity-40" style="background-image: linear-gradient(to right, rgba(0, 83, 132, 0.15) 1px, transparent 1px), linear-gradient(to bottom, rgba(0, 83, 132, 0.15) 1px, transparent 1px); background-size: 50px 50px;"></div>
|
<div class="absolute inset-0 opacity-40" style="background-image: linear-gradient(to right, rgba(0, 83, 132, 0.15) 1px, transparent 1px), linear-gradient(to bottom, rgba(0, 83, 132, 0.15) 1px, transparent 1px); background-size: 50px 50px;"></div>
|
||||||
<div class="absolute w-3 h-3 bg-cyan-400 rounded-full network-node" style="top: 12%; left: 18%; box-shadow: 0 0 25px 8px rgba(34, 211, 238, 0.6);"></div>
|
<div class="absolute w-3 h-3 bg-cyan-400 rounded-full network-node" style="top: 12%; left: 18%; box-shadow: 0 0 25px 8px rgba(34, 211, 238, 0.6);"></div>
|
||||||
@@ -326,7 +323,6 @@ const App = {
|
|||||||
<div class="absolute w-2.5 h-2.5 bg-blue-300 rounded-full network-node" style="top: 82%; left: 85%; animation-delay: 0.3s; box-shadow: 0 0 20px 6px rgba(147, 197, 253, 0.6);"></div>
|
<div class="absolute w-2.5 h-2.5 bg-blue-300 rounded-full network-node" style="top: 82%; left: 85%; animation-delay: 0.3s; box-shadow: 0 0 20px 6px rgba(147, 197, 253, 0.6);"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Install Card -->
|
|
||||||
<div class="relative bg-white/95 dark:bg-slate-800/95 backdrop-blur-sm rounded-2xl shadow-2xl p-6 w-full max-w-sm border border-white/20 dark:border-slate-700/50">
|
<div class="relative bg-white/95 dark:bg-slate-800/95 backdrop-blur-sm rounded-2xl shadow-2xl p-6 w-full max-w-sm border border-white/20 dark:border-slate-700/50">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<img src="/assets/images/xinon-full-transparent.png" class="h-14 mx-auto dark:hidden" alt="Logo">
|
<img src="/assets/images/xinon-full-transparent.png" class="h-14 mx-auto dark:hidden" alt="Logo">
|
||||||
@@ -345,7 +341,6 @@ const App = {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Android Install Button -->
|
|
||||||
<div v-if="isAndroid && deferredInstallPrompt">
|
<div v-if="isAndroid && deferredInstallPrompt">
|
||||||
<button
|
<button
|
||||||
@click="triggerInstall"
|
@click="triggerInstall"
|
||||||
@@ -358,7 +353,6 @@ const App = {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- iOS Instructions -->
|
|
||||||
<div v-else-if="isIOS" class="space-y-4">
|
<div v-else-if="isIOS" class="space-y-4">
|
||||||
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4">
|
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4">
|
||||||
<p class="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">So installierst du die App:</p>
|
<p class="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">So installierst du die App:</p>
|
||||||
@@ -383,7 +377,6 @@ const App = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Android Manual Instructions (fallback) -->
|
|
||||||
<div v-else-if="isAndroid" class="space-y-4">
|
<div v-else-if="isAndroid" class="space-y-4">
|
||||||
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4">
|
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4">
|
||||||
<p class="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">So installierst du die App:</p>
|
<p class="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">So installierst du die App:</p>
|
||||||
@@ -404,7 +397,6 @@ const App = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop / Unknown -->
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
|
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
|
||||||
<p class="text-sm text-amber-700 dark:text-amber-400">
|
<p class="text-sm text-amber-700 dark:text-amber-400">
|
||||||
@@ -421,7 +413,6 @@ const App = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Login Screen -->
|
|
||||||
<LoginScreen
|
<LoginScreen
|
||||||
v-else-if="currentView === 'login'"
|
v-else-if="currentView === 'login'"
|
||||||
@login="handleLogin"
|
@login="handleLogin"
|
||||||
@@ -429,12 +420,9 @@ const App = {
|
|||||||
@set-theme="setTheme"
|
@set-theme="setTheme"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Main App -->
|
|
||||||
<template v-else-if="currentView === 'app'">
|
<template v-else-if="currentView === 'app'">
|
||||||
<div class="h-full flex flex-col">
|
<div class="h-full flex flex-col">
|
||||||
<!-- Persistent Header -->
|
|
||||||
<header class="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 px-2 py-2 flex items-center safe-area-top flex-shrink-0 z-10">
|
<header class="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 px-2 py-2 flex items-center safe-area-top flex-shrink-0 z-10">
|
||||||
<!-- Left: Back Button -->
|
|
||||||
<button
|
<button
|
||||||
@click="goBack"
|
@click="goBack"
|
||||||
:class="[
|
:class="[
|
||||||
@@ -449,13 +437,11 @@ const App = {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Center: Logo -->
|
|
||||||
<div class="flex-1 flex justify-center">
|
<div class="flex-1 flex justify-center">
|
||||||
<img src="/assets/images/xinon-full-transparent.png" class="h-7 dark:hidden" alt="Logo">
|
<img src="/assets/images/xinon-full-transparent.png" class="h-7 dark:hidden" alt="Logo">
|
||||||
<img src="/assets/images/xinon-full-transparent-white.png" class="h-7 hidden dark:block" alt="Logo">
|
<img src="/assets/images/xinon-full-transparent-white.png" class="h-7 hidden dark:block" alt="Logo">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: Settings -->
|
|
||||||
<button
|
<button
|
||||||
@click="showSettings = true"
|
@click="showSettings = true"
|
||||||
class="w-10 h-10 flex items-center justify-center rounded-full text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 transition"
|
class="w-10 h-10 flex items-center justify-center rounded-full text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 transition"
|
||||||
@@ -467,8 +453,30 @@ const App = {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Content Area -->
|
|
||||||
<main class="flex-1 overflow-y-auto">
|
<main class="flex-1 overflow-y-auto">
|
||||||
|
<div v-if="showContinuePrompt && !currentModule" class="p-3 pb-0">
|
||||||
|
<div class="bg-primary/10 dark:bg-primary/20 border border-primary/30 rounded-xl p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-xs text-primary/70 dark:text-primary/80 font-medium uppercase tracking-wide">Fortsetzen</p>
|
||||||
|
<p class="font-semibold text-slate-800 dark:text-white truncate">
|
||||||
|
{{ lastWorkflow?.module }}<template v-if="lastWorkflow?.submodule"> › {{ lastWorkflow.submodule }}</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 ml-3">
|
||||||
|
<button @click="dismissContinuePrompt" class="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button @click="continueLastWorkflow" class="px-4 py-2 bg-primary text-white font-bold rounded-lg hover:bg-primary/90 transition">
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<MainMenu
|
<MainMenu
|
||||||
v-if="!currentModule"
|
v-if="!currentModule"
|
||||||
:user="user"
|
:user="user"
|
||||||
@@ -483,10 +491,22 @@ const App = {
|
|||||||
@navigate="navigate"
|
@navigate="navigate"
|
||||||
@toast="showToast"
|
@toast="showToast"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ShippingNoteModule
|
||||||
|
v-else-if="currentModule?.toLowerCase() === 'lieferschein'"
|
||||||
|
:user="user"
|
||||||
|
@toast="showToast"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WorkorderModule
|
||||||
|
v-else-if="currentModule?.toLowerCase() === 'workorder'"
|
||||||
|
:user="user"
|
||||||
|
@navigate="navigate"
|
||||||
|
@toast="showToast"
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings Panel -->
|
|
||||||
<transition name="slide-right">
|
<transition name="slide-right">
|
||||||
<div v-if="showSettings" class="fixed inset-0 z-50">
|
<div v-if="showSettings" class="fixed inset-0 z-50">
|
||||||
<div class="absolute inset-0 bg-black/40" @click="showSettings = false"></div>
|
<div class="absolute inset-0 bg-black/40" @click="showSettings = false"></div>
|
||||||
@@ -500,13 +520,11 @@ const App = {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div class="flex-1 overflow-y-auto">
|
||||||
<!-- User Info -->
|
|
||||||
<div class="px-4 py-3 border-b border-slate-100 dark:border-slate-700">
|
<div class="px-4 py-3 border-b border-slate-100 dark:border-slate-700">
|
||||||
<p class="font-medium text-slate-800 dark:text-white">{{ user?.name }}</p>
|
<p class="font-medium text-slate-800 dark:text-white">{{ user?.name }}</p>
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400">{{ user?.username }}</p>
|
<p class="text-sm text-slate-500 dark:text-slate-400">{{ user?.username }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Theme Selection -->
|
|
||||||
<div class="px-4 py-3">
|
<div class="px-4 py-3">
|
||||||
<p class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-2">Farbschema</p>
|
<p class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-2">Farbschema</p>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
@@ -525,7 +543,6 @@ const App = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lager Settings -->
|
|
||||||
<div class="px-4 py-3 border-t border-slate-100 dark:border-slate-700">
|
<div class="px-4 py-3 border-t border-slate-100 dark:border-slate-700">
|
||||||
<p class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-3">Lager</p>
|
<p class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-3">Lager</p>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -549,7 +566,6 @@ const App = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logout at bottom -->
|
|
||||||
<div class="p-3 border-t border-slate-100 dark:border-slate-700">
|
<div class="p-3 border-t border-slate-100 dark:border-slate-700">
|
||||||
<button
|
<button
|
||||||
@click="showSettings = false; handleLogout()"
|
@click="showSettings = false; handleLogout()"
|
||||||
@@ -566,7 +582,6 @@ const App = {
|
|||||||
</transition>
|
</transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Toast Notifications -->
|
|
||||||
<transition name="slide-up">
|
<transition name="slide-up">
|
||||||
<div v-if="toast.show" class="toast-container">
|
<div v-if="toast.show" class="toast-container">
|
||||||
<div :class="['toast', 'toast-' + toast.type]">
|
<div :class="['toast', 'toast-' + toast.type]">
|
||||||
|
|||||||
@@ -1,13 +1,3 @@
|
|||||||
/**
|
|
||||||
* LoginScreen Component
|
|
||||||
*
|
|
||||||
* Displays the login form for the PWA with 2FA support.
|
|
||||||
* Features:
|
|
||||||
* - Username/password authentication
|
|
||||||
* - 2FA verification with OTP auto-detection (Web OTP API for Android, autocomplete for iOS)
|
|
||||||
* - Remember me option
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { login, verify2FA, resend2FA } from '/mobile/shared/auth.js';
|
import { login, verify2FA, resend2FA } from '/mobile/shared/auth.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -23,32 +13,24 @@ export default {
|
|||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const { ref, onMounted, onUnmounted, nextTick } = Vue;
|
const { ref, onMounted, onUnmounted, nextTick } = Vue;
|
||||||
|
|
||||||
// Login form state
|
|
||||||
const username = ref('');
|
const username = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
const rememberMe = ref(true);
|
const rememberMe = ref(true);
|
||||||
const showPassword = ref(false);
|
const showPassword = ref(false);
|
||||||
|
|
||||||
// 2FA state
|
|
||||||
const show2FA = ref(false);
|
const show2FA = ref(false);
|
||||||
const otpCode = ref('');
|
const otpCode = ref('');
|
||||||
const otpDigits = ref(['', '', '', '', '']);
|
const otpDigits = ref(['', '', '', '', '']);
|
||||||
const deliveryMethod = ref('');
|
const deliveryMethod = ref('');
|
||||||
const maskedTarget = ref('');
|
const maskedTarget = ref('');
|
||||||
const resendCooldown = ref(0);
|
const resendCooldown = ref(0);
|
||||||
|
|
||||||
// General state
|
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const success = ref('');
|
const success = ref('');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const showThemePicker = ref(!localStorage.getItem('theme'));
|
const showThemePicker = ref(false);
|
||||||
|
|
||||||
// OTP input refs
|
|
||||||
let otpInputRefs = [];
|
let otpInputRefs = [];
|
||||||
let otpAbortController = null;
|
let otpAbortController = null;
|
||||||
let resendTimer = null;
|
let resendTimer = null;
|
||||||
|
|
||||||
// Handle login form submission
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!username.value || !password.value) {
|
if (!username.value || !password.value) {
|
||||||
error.value = 'Bitte Benutzername und Passwort eingeben';
|
error.value = 'Bitte Benutzername und Passwort eingeben';
|
||||||
@@ -59,7 +41,6 @@ export default {
|
|||||||
error.value = '';
|
error.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Call login API directly
|
|
||||||
const result = await login({
|
const result = await login({
|
||||||
username: username.value,
|
username: username.value,
|
||||||
password: password.value,
|
password: password.value,
|
||||||
@@ -168,20 +149,20 @@ export default {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Go back to login form
|
|
||||||
const backToLogin = () => {
|
const backToLogin = () => {
|
||||||
show2FA.value = false;
|
show2FA.value = false;
|
||||||
otpDigits.value = ['', '', '', '', ''];
|
otpDigits.value = ['', '', '', '', ''];
|
||||||
|
otpInputRefs = [];
|
||||||
error.value = '';
|
error.value = '';
|
||||||
success.value = '';
|
success.value = '';
|
||||||
abortWebOTP();
|
abortWebOTP();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reset after session expired
|
|
||||||
const resetTo2FA = () => {
|
const resetTo2FA = () => {
|
||||||
show2FA.value = false;
|
show2FA.value = false;
|
||||||
password.value = '';
|
password.value = '';
|
||||||
otpDigits.value = ['', '', '', '', ''];
|
otpDigits.value = ['', '', '', '', ''];
|
||||||
|
otpInputRefs = [];
|
||||||
error.value = 'Sitzung abgelaufen. Bitte erneut anmelden.';
|
error.value = 'Sitzung abgelaufen. Bitte erneut anmelden.';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -197,11 +178,12 @@ export default {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// OTP input handlers
|
|
||||||
const focusOtpInput = (index) => {
|
const focusOtpInput = (index) => {
|
||||||
const inputs = document.querySelectorAll('.otp-input');
|
if (otpInputRefs.length === 0) {
|
||||||
if (inputs[index]) {
|
otpInputRefs = Array.from(document.querySelectorAll('.otp-input'));
|
||||||
inputs[index].focus();
|
}
|
||||||
|
if (otpInputRefs[index]) {
|
||||||
|
otpInputRefs[index].focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ export default {
|
|||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
// Available modules
|
// Available modules
|
||||||
const modules = [
|
const modules = [
|
||||||
|
{
|
||||||
|
id: 'Workorder',
|
||||||
|
name: 'Aufträge',
|
||||||
|
icon: 'clipboard-check',
|
||||||
|
color: 'bg-sky-500',
|
||||||
|
iconColor: 'text-sky-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Lieferschein',
|
||||||
|
name: 'Lieferschein',
|
||||||
|
icon: 'document',
|
||||||
|
color: 'bg-purple-500',
|
||||||
|
iconColor: 'text-purple-500'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'Lager',
|
id: 'Lager',
|
||||||
name: 'Lager',
|
name: 'Lager',
|
||||||
@@ -22,7 +36,6 @@ export default {
|
|||||||
color: 'bg-blue-500',
|
color: 'bg-blue-500',
|
||||||
iconColor: 'text-blue-500'
|
iconColor: 'text-blue-500'
|
||||||
}
|
}
|
||||||
// Future modules can be added here
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const openModule = (moduleId) => {
|
const openModule = (moduleId) => {
|
||||||
@@ -49,7 +62,13 @@ export default {
|
|||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div :class="[module.color, 'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0']">
|
<div :class="[module.color, 'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0']">
|
||||||
<svg v-if="module.icon === 'warehouse'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg v-if="module.icon === 'clipboard-check'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else-if="module.icon === 'document'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else-if="module.icon === 'warehouse'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,6 @@
|
|||||||
/**
|
import { createModuleApi, debounce } from '/mobile/shared/api.js';
|
||||||
* Scanner Component (Inventur)
|
|
||||||
*
|
|
||||||
* The main scanning interface for stocktakes.
|
|
||||||
* Uses the new API path: /MobileApp/Lager/Inventur/{action}
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Inventur-specific API
|
const inventurApi = createModuleApi('Lager/Inventur');
|
||||||
const inventurApi = {
|
|
||||||
get: (endpoint) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`).then(r => r.json()),
|
|
||||||
post: (endpoint, data) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
}).then(r => r.json())
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Scanner',
|
name: 'Scanner',
|
||||||
@@ -26,44 +13,29 @@ export default {
|
|||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const { ref, onMounted, onUnmounted, nextTick, computed } = Vue;
|
const { ref, onMounted, onUnmounted, nextTick, computed } = Vue;
|
||||||
|
|
||||||
// State
|
|
||||||
const currentTab = ref('scan');
|
const currentTab = ref('scan');
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
|
||||||
// Scanner
|
|
||||||
const scanner = ref(null);
|
const scanner = ref(null);
|
||||||
const isScannerActive = ref(false);
|
const isScannerActive = ref(false);
|
||||||
const scannerError = ref('');
|
const scannerError = ref('');
|
||||||
|
|
||||||
// Article
|
|
||||||
const scannedArticle = ref(null);
|
const scannedArticle = ref(null);
|
||||||
const quantity = ref('1');
|
const quantity = ref('1');
|
||||||
const rack = ref('');
|
const rack = ref('');
|
||||||
const shelf = ref('');
|
const shelf = ref('');
|
||||||
|
|
||||||
// Search
|
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const searchResults = ref([]);
|
const searchResults = ref([]);
|
||||||
const categories = ref([]);
|
const categories = ref([]);
|
||||||
const selectedCategory = ref(0);
|
const selectedCategory = ref(0);
|
||||||
const isSearching = ref(false);
|
const isSearching = ref(false);
|
||||||
|
|
||||||
// History
|
|
||||||
const recentScans = ref([]);
|
const recentScans = ref([]);
|
||||||
const isLoadingHistory = ref(false);
|
const isLoadingHistory = ref(false);
|
||||||
|
|
||||||
// Warning
|
|
||||||
const alreadyScannedWarning = ref(null);
|
const alreadyScannedWarning = ref(null);
|
||||||
|
|
||||||
// Keypad
|
|
||||||
const showKeypad = ref(false);
|
const showKeypad = ref(false);
|
||||||
|
|
||||||
// Computed
|
|
||||||
const canSubmit = computed(() => {
|
const canSubmit = computed(() => {
|
||||||
return scannedArticle.value && parseFloat(quantity.value) > 0 && !isLoading.value;
|
return scannedArticle.value && parseFloat(quantity.value) > 0 && !isLoading.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Scanner functions
|
|
||||||
const startScanner = async () => {
|
const startScanner = async () => {
|
||||||
scannerError.value = '';
|
scannerError.value = '';
|
||||||
try {
|
try {
|
||||||
@@ -92,7 +64,6 @@ export default {
|
|||||||
await lookupArticle(decodedText);
|
await lookupArticle(decodedText);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Article lookup
|
|
||||||
const lookupArticle = async (code) => {
|
const lookupArticle = async (code) => {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
alreadyScannedWarning.value = null;
|
alreadyScannedWarning.value = null;
|
||||||
@@ -121,7 +92,6 @@ export default {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Submit
|
|
||||||
const submitScan = async (overwrite = false) => {
|
const submitScan = async (overwrite = false) => {
|
||||||
if (!canSubmit.value) return;
|
if (!canSubmit.value) return;
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
@@ -140,6 +110,7 @@ export default {
|
|||||||
const result = await inventurApi.post('submitScan', payload);
|
const result = await inventurApi.post('submitScan', payload);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
navigator.vibrate?.([100]);
|
||||||
emit('toast', result.message, 'success');
|
emit('toast', result.message, 'success');
|
||||||
scannedArticle.value = null;
|
scannedArticle.value = null;
|
||||||
quantity.value = '1';
|
quantity.value = '1';
|
||||||
@@ -157,13 +128,12 @@ export default {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Search
|
|
||||||
const loadCategories = async () => {
|
const loadCategories = async () => {
|
||||||
const result = await inventurApi.get('getCategories');
|
const result = await inventurApi.get('getCategories');
|
||||||
if (result.success) categories.value = result.categories;
|
if (result.success) categories.value = result.categories;
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchArticles = async () => {
|
const doSearch = async () => {
|
||||||
if (searchQuery.value.length < 2 && !selectedCategory.value) {
|
if (searchQuery.value.length < 2 && !selectedCategory.value) {
|
||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
return;
|
return;
|
||||||
@@ -180,6 +150,8 @@ export default {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const searchArticles = debounce(doSearch, 300);
|
||||||
|
|
||||||
const selectSearchResult = async (article) => {
|
const selectSearchResult = async (article) => {
|
||||||
await stopScanner();
|
await stopScanner();
|
||||||
scannedArticle.value = article;
|
scannedArticle.value = article;
|
||||||
@@ -194,7 +166,6 @@ export default {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// History
|
|
||||||
const loadHistory = async () => {
|
const loadHistory = async () => {
|
||||||
isLoadingHistory.value = true;
|
isLoadingHistory.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -205,7 +176,6 @@ export default {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keypad
|
|
||||||
const appendDigit = (digit) => {
|
const appendDigit = (digit) => {
|
||||||
if (digit === '.' && quantity.value.includes('.')) return;
|
if (digit === '.' && quantity.value.includes('.')) return;
|
||||||
if (quantity.value === '0' && digit !== '.') {
|
if (quantity.value === '0' && digit !== '.') {
|
||||||
@@ -221,7 +191,6 @@ export default {
|
|||||||
|
|
||||||
const clearQuantity = () => { quantity.value = '0'; };
|
const clearQuantity = () => { quantity.value = '0'; };
|
||||||
|
|
||||||
// Navigation
|
|
||||||
const handleClose = async () => {
|
const handleClose = async () => {
|
||||||
await stopScanner();
|
await stopScanner();
|
||||||
emit('close');
|
emit('close');
|
||||||
@@ -265,7 +234,6 @@ export default {
|
|||||||
|
|
||||||
template: `
|
template: `
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Title bar with close -->
|
|
||||||
<div class="flex items-center justify-between px-3 py-2 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
|
<div class="flex items-center justify-between px-3 py-2 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
|
||||||
<span class="text-sm font-medium text-slate-600 dark:text-slate-300 truncate flex-1">{{ stocktake.title || 'Inventur #' + stocktake.stocktakeNumber }}</span>
|
<span class="text-sm font-medium text-slate-600 dark:text-slate-300 truncate flex-1">{{ stocktake.title || 'Inventur #' + stocktake.stocktakeNumber }}</span>
|
||||||
<button @click="handleClose" class="ml-2 p-1.5 rounded-full text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 transition">
|
<button @click="handleClose" class="ml-2 p-1.5 rounded-full text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 transition">
|
||||||
@@ -275,18 +243,14 @@ export default {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
|
||||||
<div class="flex bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 px-3 py-2">
|
<div class="flex bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 px-3 py-2">
|
||||||
<button @click="switchTab('scan')" :class="[currentTab === 'scan' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition">Scannen</button>
|
<button @click="switchTab('scan')" :class="[currentTab === 'scan' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition">Scannen</button>
|
||||||
<button @click="switchTab('search')" :class="[currentTab === 'search' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition mx-1">Suche</button>
|
<button @click="switchTab('search')" :class="[currentTab === 'search' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition mx-1">Suche</button>
|
||||||
<button @click="switchTab('history')" :class="[currentTab === 'history' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition">Verlauf</button>
|
<button @click="switchTab('history')" :class="[currentTab === 'history' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition">Verlauf</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<main class="flex-grow overflow-y-auto bg-slate-50 dark:bg-slate-900">
|
<main class="flex-grow overflow-y-auto bg-slate-50 dark:bg-slate-900">
|
||||||
<!-- SCAN TAB -->
|
|
||||||
<div v-if="currentTab === 'scan'" class="p-4 space-y-4">
|
<div v-if="currentTab === 'scan'" class="p-4 space-y-4">
|
||||||
<!-- Scanner -->
|
|
||||||
<div v-if="!scannedArticle" class="space-y-4">
|
<div v-if="!scannedArticle" class="space-y-4">
|
||||||
<div id="qr-reader" class="w-full rounded-lg overflow-hidden bg-black"></div>
|
<div id="qr-reader" class="w-full rounded-lg overflow-hidden bg-black"></div>
|
||||||
<div v-if="scannerError" class="p-4 bg-red-50 dark:bg-red-900/30 rounded-lg">
|
<div v-if="scannerError" class="p-4 bg-red-50 dark:bg-red-900/30 rounded-lg">
|
||||||
@@ -296,9 +260,7 @@ export default {
|
|||||||
<p class="text-center text-sm text-slate-500 dark:text-slate-400">QR-Code scannen oder Artikel suchen</p>
|
<p class="text-center text-sm text-slate-500 dark:text-slate-400">QR-Code scannen oder Artikel suchen</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scanned Article -->
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<!-- 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 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">
|
<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">
|
<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">
|
||||||
@@ -315,24 +277,31 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Article Info -->
|
|
||||||
<div class="bg-white dark:bg-slate-800 p-4 rounded-lg shadow-sm">
|
<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>
|
<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 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>
|
<p v-if="scannedArticle.categoryName" class="text-sm text-slate-500 dark:text-slate-400">Kategorie: {{ scannedArticle.categoryName }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quantity -->
|
|
||||||
<div class="bg-white dark:bg-slate-800 p-4 rounded-lg shadow-sm">
|
<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">
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
Menge ({{ scannedArticle.unit || 'Stk.' }})
|
Menge ({{ scannedArticle.unit || 'Stk.' }})
|
||||||
</label>
|
</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">
|
<div class="flex gap-2 mb-3">
|
||||||
{{ quantity }}
|
<button @click="quantity = '1'" :class="['flex-1 py-2 rounded-lg font-bold transition', quantity === '1' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">1</button>
|
||||||
|
<button @click="quantity = '5'" :class="['flex-1 py-2 rounded-lg font-bold transition', quantity === '5' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">5</button>
|
||||||
|
<button @click="quantity = '10'" :class="['flex-1 py-2 rounded-lg font-bold transition', quantity === '10' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">10</button>
|
||||||
|
<button @click="quantity = '20'" :class="['flex-1 py-2 rounded-lg font-bold transition', quantity === '20' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">20</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button @click="quantity = String(Math.max(1, parseFloat(quantity) - 1))" class="w-14 h-14 bg-slate-100 dark:bg-slate-700 rounded-lg text-2xl font-bold text-slate-700 dark:text-slate-300 active:bg-slate-200 dark:active:bg-slate-600">-</button>
|
||||||
|
<div @click="showKeypad = true" class="flex-1 p-3 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>
|
||||||
|
<button @click="quantity = String(parseFloat(quantity) + 1)" class="w-14 h-14 bg-slate-100 dark:bg-slate-700 rounded-lg text-2xl font-bold text-slate-700 dark:text-slate-300 active:bg-slate-200 dark:active:bg-slate-600">+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Rack/Shelf -->
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Regal</label>
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Regal</label>
|
||||||
@@ -344,19 +313,17 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Buttons -->
|
|
||||||
<div class="space-y-2">
|
<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">Zur Menge addieren</button>
|
<button v-if="alreadyScannedWarning" @click="submitScan(false)" :disabled="!canSubmit" class="w-full py-5 bg-green-600 text-white text-lg font-bold rounded-xl disabled:opacity-50 active:scale-[0.98] transition">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">Überschreiben</button>
|
<button v-if="alreadyScannedWarning" @click="submitScan(true)" :disabled="!canSubmit" class="w-full py-4 bg-amber-600 text-white font-bold rounded-xl disabled:opacity-50 active:scale-[0.98] transition">Ü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">
|
<button v-if="!alreadyScannedWarning" @click="submitScan(false)" :disabled="!canSubmit" class="w-full py-5 bg-green-600 text-white text-lg font-bold rounded-xl disabled:opacity-50 active:scale-[0.98] transition">
|
||||||
{{ isLoading ? 'Speichert...' : 'Speichern' }}
|
{{ isLoading ? 'Speichert...' : 'Speichern' }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="cancelScan" class="w-full py-3 text-slate-600 dark:text-slate-400 font-medium">Abbrechen</button>
|
<button @click="cancelScan" class="w-full py-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300">Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SEARCH TAB -->
|
|
||||||
<div v-else-if="currentTab === 'search'" class="p-4 space-y-4">
|
<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">
|
<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">
|
<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">
|
||||||
@@ -382,7 +349,6 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- HISTORY TAB -->
|
|
||||||
<div v-else-if="currentTab === 'history'" class="p-4">
|
<div v-else-if="currentTab === 'history'" class="p-4">
|
||||||
<div v-if="isLoadingHistory" class="space-y-3">
|
<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 v-for="i in 5" :key="i" class="bg-white dark:bg-slate-800 p-3 rounded-lg shadow-sm animate-pulse">
|
||||||
@@ -412,7 +378,6 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Keypad -->
|
|
||||||
<transition name="slide-up">
|
<transition name="slide-up">
|
||||||
<div v-if="showKeypad" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end">
|
<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="bg-white dark:bg-slate-800 w-full rounded-t-2xl p-4 safe-area-bottom">
|
||||||
|
|||||||
@@ -1,21 +1,6 @@
|
|||||||
/**
|
import { createModuleApi } from '/mobile/shared/api.js';
|
||||||
* StocktakeList Component (Inventur)
|
|
||||||
*
|
|
||||||
* Displays a list of active stocktakes.
|
|
||||||
* Uses the new API path: /MobileApp/Lager/Inventur/{action}
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { api } from '/mobile/shared/auth.js';
|
const inventurApi = createModuleApi('Lager/Inventur');
|
||||||
|
|
||||||
// Override API base for Inventur
|
|
||||||
const inventurApi = {
|
|
||||||
get: (endpoint) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`).then(r => r.json()),
|
|
||||||
post: (endpoint, data) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
}).then(r => r.json())
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'StocktakeList',
|
name: 'StocktakeList',
|
||||||
|
|||||||
@@ -1,20 +1,8 @@
|
|||||||
/**
|
import { createModuleApi, debounce } from '/mobile/shared/api.js';
|
||||||
* MovementForm Component (WarehouseMovement)
|
|
||||||
*
|
const movementApi = createModuleApi('Lager/Movement');
|
||||||
* The main interface for stock movements (IN/OUT/ADJUSTMENT).
|
|
||||||
* API: /MobileApp/Lager/Movement/{action}
|
|
||||||
*/
|
|
||||||
|
|
||||||
const movementApi = {
|
|
||||||
get: (endpoint) => fetch(`/MobileApp/Lager/Movement/${endpoint}`).then(r => r.json()),
|
|
||||||
post: (endpoint, data) => fetch(`/MobileApp/Lager/Movement/${endpoint}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
}).then(r => r.json())
|
|
||||||
};
|
|
||||||
|
|
||||||
// Custom BottomSheet Select Component
|
|
||||||
const BottomSheetSelect = {
|
const BottomSheetSelect = {
|
||||||
name: 'BottomSheetSelect',
|
name: 'BottomSheetSelect',
|
||||||
emits: ['update:modelValue'],
|
emits: ['update:modelValue'],
|
||||||
@@ -212,6 +200,14 @@ export default {
|
|||||||
const recentMovements = ref([]);
|
const recentMovements = ref([]);
|
||||||
const isLoadingHistory = ref(false);
|
const isLoadingHistory = ref(false);
|
||||||
|
|
||||||
|
// ==================== ORDER RECEIVING ====================
|
||||||
|
const pendingOrders = ref([]);
|
||||||
|
const isLoadingOrders = ref(false);
|
||||||
|
const selectedOrder = ref(null);
|
||||||
|
const orderPositions = ref([]);
|
||||||
|
const deliveryNotePhoto = ref(null);
|
||||||
|
const isSubmittingOrder = ref(false);
|
||||||
|
|
||||||
// ==================== KEYPAD ====================
|
// ==================== KEYPAD ====================
|
||||||
const showKeypad = ref(false);
|
const showKeypad = ref(false);
|
||||||
const showNote = ref(false);
|
const showNote = ref(false);
|
||||||
@@ -569,6 +565,9 @@ export default {
|
|||||||
const result = await movementApi.post('submitMovement', payload);
|
const result = await movementApi.post('submitMovement', payload);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
// Haptic feedback on success
|
||||||
|
navigator.vibrate?.([100]);
|
||||||
|
|
||||||
// Store for undo
|
// Store for undo
|
||||||
lastMovement.value = result.movement;
|
lastMovement.value = result.movement;
|
||||||
showUndo.value = true;
|
showUndo.value = true;
|
||||||
@@ -667,6 +666,8 @@ export default {
|
|||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
|
|
||||||
if (errorCount === 0) {
|
if (errorCount === 0) {
|
||||||
|
// Haptic feedback on success
|
||||||
|
navigator.vibrate?.([100, 50, 100]);
|
||||||
emit('toast', `${successCount} Bewegungen erfolgreich`, 'success');
|
emit('toast', `${successCount} Bewegungen erfolgreich`, 'success');
|
||||||
clearCart();
|
clearCart();
|
||||||
} else {
|
} else {
|
||||||
@@ -709,6 +710,8 @@ export default {
|
|||||||
const result = await movementApi.post('submitMovement', payload);
|
const result = await movementApi.post('submitMovement', payload);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
// Haptic feedback on success
|
||||||
|
navigator.vibrate?.([100]);
|
||||||
emit('toast', result.message, 'success');
|
emit('toast', result.message, 'success');
|
||||||
// Reset form
|
// Reset form
|
||||||
scannedArticle.value = null;
|
scannedArticle.value = null;
|
||||||
@@ -727,8 +730,7 @@ export default {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Search
|
const doSearch = async () => {
|
||||||
const searchArticles = async () => {
|
|
||||||
if (searchQuery.value.length < 2) {
|
if (searchQuery.value.length < 2) {
|
||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
return;
|
return;
|
||||||
@@ -742,6 +744,8 @@ export default {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const searchArticles = debounce(doSearch, 300);
|
||||||
|
|
||||||
const selectSearchResult = async (article) => {
|
const selectSearchResult = async (article) => {
|
||||||
await stopScanner();
|
await stopScanner();
|
||||||
scannedArticle.value = article;
|
scannedArticle.value = article;
|
||||||
@@ -767,6 +771,97 @@ export default {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==================== ORDER RECEIVING FUNCTIONS ====================
|
||||||
|
const loadPendingOrders = async () => {
|
||||||
|
isLoadingOrders.value = true;
|
||||||
|
try {
|
||||||
|
const result = await movementApi.get('getPendingOrders');
|
||||||
|
if (result.success) {
|
||||||
|
pendingOrders.value = result.orders;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
emit('toast', 'Fehler beim Laden der Bestellungen', 'error');
|
||||||
|
} finally {
|
||||||
|
isLoadingOrders.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectOrderForReceiving = async (order) => {
|
||||||
|
isLoadingOrders.value = true;
|
||||||
|
try {
|
||||||
|
const result = await movementApi.get(`getOrderForReceiving?orderId=${order.id}`);
|
||||||
|
if (result.success) {
|
||||||
|
selectedOrder.value = result.order;
|
||||||
|
orderPositions.value = result.positions;
|
||||||
|
deliveryNotePhoto.value = null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
emit('toast', 'Fehler beim Laden der Bestellung', 'error');
|
||||||
|
} finally {
|
||||||
|
isLoadingOrders.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelOrderReceiving = () => {
|
||||||
|
selectedOrder.value = null;
|
||||||
|
orderPositions.value = [];
|
||||||
|
deliveryNotePhoto.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitOrderReceiving = async () => {
|
||||||
|
if (!selectedOrder.value || !selectedLocation.value) return;
|
||||||
|
|
||||||
|
// Collect positions with quantity > 0
|
||||||
|
const positionsToSubmit = orderPositions.value
|
||||||
|
.filter(p => p.receivingQty > 0)
|
||||||
|
.map(p => ({
|
||||||
|
articleId: p.articleId,
|
||||||
|
quantity: p.receivingQty
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (positionsToSubmit.length === 0) {
|
||||||
|
emit('toast', 'Bitte mindestens eine Menge eingeben', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmittingOrder.value = true;
|
||||||
|
try {
|
||||||
|
const result = await movementApi.post('submitOrderReceiving', {
|
||||||
|
orderId: selectedOrder.value.id,
|
||||||
|
locationId: selectedLocation.value,
|
||||||
|
positions: positionsToSubmit,
|
||||||
|
deliveryNoteFileId: deliveryNotePhoto.value,
|
||||||
|
note: null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
navigator.vibrate?.([100, 50, 100]);
|
||||||
|
emit('toast', result.message, 'success');
|
||||||
|
// Reset and reload orders
|
||||||
|
cancelOrderReceiving();
|
||||||
|
await loadPendingOrders();
|
||||||
|
} else {
|
||||||
|
emit('toast', result.message || 'Fehler beim Speichern', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
emit('toast', 'Netzwerkfehler', 'error');
|
||||||
|
} finally {
|
||||||
|
isSubmittingOrder.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAllReceivingQty = () => {
|
||||||
|
orderPositions.value.forEach(p => {
|
||||||
|
p.receivingQty = p.remainingQty;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAllReceivingQty = () => {
|
||||||
|
orderPositions.value.forEach(p => {
|
||||||
|
p.receivingQty = 0;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Keypad
|
// Keypad
|
||||||
const appendDigit = (digit) => {
|
const appendDigit = (digit) => {
|
||||||
if (digit === '.' && quantity.value.includes('.')) return;
|
if (digit === '.' && quantity.value.includes('.')) return;
|
||||||
@@ -794,6 +889,9 @@ export default {
|
|||||||
} else if (tab === 'history') {
|
} else if (tab === 'history') {
|
||||||
await stopScanner();
|
await stopScanner();
|
||||||
await loadHistory();
|
await loadHistory();
|
||||||
|
} else if (tab === 'orders') {
|
||||||
|
await stopScanner();
|
||||||
|
await loadPendingOrders();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -849,6 +947,11 @@ export default {
|
|||||||
searchQuery, searchResults, isSearching,
|
searchQuery, searchResults, isSearching,
|
||||||
// History
|
// History
|
||||||
recentMovements, isLoadingHistory,
|
recentMovements, isLoadingHistory,
|
||||||
|
// Order Receiving
|
||||||
|
pendingOrders, isLoadingOrders, selectedOrder, orderPositions,
|
||||||
|
deliveryNotePhoto, isSubmittingOrder,
|
||||||
|
loadPendingOrders, selectOrderForReceiving, cancelOrderReceiving,
|
||||||
|
submitOrderReceiving, setAllReceivingQty, clearAllReceivingQty,
|
||||||
// UI
|
// UI
|
||||||
showKeypad, showNote, canSubmit, typeColor,
|
showKeypad, showNote, canSubmit, typeColor,
|
||||||
// Functions
|
// Functions
|
||||||
@@ -923,29 +1026,30 @@ export default {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mode Toggles & Quick Actions Bar (hidden in simple mode) -->
|
<!-- Mode Toggles (hidden in simple mode) -->
|
||||||
<div v-if="!simpleMode" class="flex items-center gap-2">
|
<div v-if="!simpleMode" class="flex items-center gap-2">
|
||||||
<!-- Turbo Mode Toggle -->
|
<!-- Turbo Mode Toggle - More Prominent -->
|
||||||
<button
|
<button
|
||||||
@click="turboMode = !turboMode"
|
@click="turboMode = !turboMode"
|
||||||
:class="[
|
:class="[
|
||||||
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-all',
|
'flex items-center gap-2 px-4 py-2.5 rounded-xl font-medium transition-all flex-1',
|
||||||
turboMode
|
turboMode
|
||||||
? 'bg-orange-500 text-white'
|
? 'bg-gradient-to-r from-orange-500 to-orange-600 text-white shadow-lg shadow-orange-500/30'
|
||||||
: 'bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700'
|
: 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 border-2 border-dashed border-orange-300 dark:border-orange-800 hover:border-orange-400'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
Turbo
|
<span class="text-sm">Turbo-Modus</span>
|
||||||
|
<span v-if="!turboMode" class="text-xs opacity-60">(1-Klick)</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Batch Mode Toggle -->
|
<!-- Batch Mode Toggle -->
|
||||||
<button
|
<button
|
||||||
@click="batchMode = !batchMode"
|
@click="batchMode = !batchMode"
|
||||||
:class="[
|
:class="[
|
||||||
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-all',
|
'flex items-center gap-1.5 px-3 py-2.5 rounded-xl text-sm font-medium transition-all',
|
||||||
batchMode
|
batchMode
|
||||||
? 'bg-purple-500 text-white'
|
? 'bg-purple-500 text-white'
|
||||||
: 'bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700'
|
: 'bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700'
|
||||||
@@ -957,18 +1061,6 @@ export default {
|
|||||||
Sammel
|
Sammel
|
||||||
<span v-if="cartTotal > 0" class="bg-white/30 px-1.5 rounded-full">{{ cartTotal }}</span>
|
<span v-if="cartTotal > 0" class="bg-white/30 px-1.5 rounded-full">{{ cartTotal }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
<button
|
|
||||||
@click="quickAction('OUT', 'Verbrauch')"
|
|
||||||
class="flex items-center gap-1 px-3 py-2 rounded-lg text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-900/50 transition"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
|
|
||||||
</svg>
|
|
||||||
Schnell
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -976,6 +1068,10 @@ export default {
|
|||||||
<div class="flex bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 px-3 py-2">
|
<div class="flex bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 px-3 py-2">
|
||||||
<button @click="switchTab('scan')" :class="[currentTab === 'scan' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition">Scannen</button>
|
<button @click="switchTab('scan')" :class="[currentTab === 'scan' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition">Scannen</button>
|
||||||
<button @click="switchTab('search')" :class="[currentTab === 'search' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition mx-1">Suche</button>
|
<button @click="switchTab('search')" :class="[currentTab === 'search' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition mx-1">Suche</button>
|
||||||
|
<button v-if="!simpleMode" @click="switchTab('orders')" :class="[currentTab === 'orders' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400', pendingOrders.length > 0 ? 'relative' : '']" class="flex-1 py-2 text-sm font-medium rounded-lg transition mx-1">
|
||||||
|
Bestellung
|
||||||
|
<span v-if="pendingOrders.length > 0 && currentTab !== 'orders'" class="absolute -top-1 -right-1 bg-blue-500 text-white text-xs w-5 h-5 rounded-full flex items-center justify-center">{{ pendingOrders.length }}</span>
|
||||||
|
</button>
|
||||||
<button v-if="!simpleMode" @click="switchTab('history')" :class="[currentTab === 'history' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition">Verlauf</button>
|
<button v-if="!simpleMode" @click="switchTab('history')" :class="[currentTab === 'history' ? 'bg-white dark:bg-slate-800 shadow-sm' : 'text-slate-500 dark:text-slate-400']" class="flex-1 py-2 text-sm font-medium rounded-lg transition">Verlauf</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1016,17 +1112,30 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quantity (Compact) -->
|
<!-- Quantity with Quick Buttons -->
|
||||||
<div @click="showKeypad = true" :class="[
|
<div class="bg-white dark:bg-slate-800 p-3 rounded-xl shadow-sm">
|
||||||
'flex items-center justify-between p-3 rounded-xl cursor-pointer transition active:scale-[0.98]',
|
<!-- Quick preset buttons -->
|
||||||
selectedType === 'IN' ? 'bg-green-500 text-white' : '',
|
<div class="flex gap-2 mb-2">
|
||||||
selectedType === 'OUT' ? 'bg-red-500 text-white' : '',
|
<button @click="quantity = '1'" :class="['flex-1 py-2 rounded-lg font-bold text-sm transition', quantity === '1' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">1</button>
|
||||||
selectedType === 'ADJUSTMENT' ? 'bg-yellow-500 text-white' : ''
|
<button @click="quantity = '5'" :class="['flex-1 py-2 rounded-lg font-bold text-sm transition', quantity === '5' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">5</button>
|
||||||
]">
|
<button @click="quantity = '10'" :class="['flex-1 py-2 rounded-lg font-bold text-sm transition', quantity === '10' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">10</button>
|
||||||
<span class="font-medium">Menge</span>
|
<button @click="quantity = '20'" :class="['flex-1 py-2 rounded-lg font-bold text-sm transition', quantity === '20' ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300']">20</button>
|
||||||
<span class="text-2xl font-bold">
|
</div>
|
||||||
{{ selectedType === 'OUT' ? '-' : selectedType === 'IN' ? '+' : '' }}{{ quantity }}
|
<!-- Quantity with +/- and colored display -->
|
||||||
</span>
|
<div class="flex items-center gap-2">
|
||||||
|
<button @click="quantity = String(Math.max(1, parseFloat(quantity) - 1))" class="w-12 h-12 bg-slate-100 dark:bg-slate-700 rounded-lg text-xl font-bold text-slate-700 dark:text-slate-300 active:bg-slate-200 dark:active:bg-slate-600">-</button>
|
||||||
|
<div @click="showKeypad = true" :class="[
|
||||||
|
'flex-1 py-3 rounded-xl cursor-pointer transition active:scale-[0.98] text-center',
|
||||||
|
selectedType === 'IN' ? 'bg-green-500 text-white' : '',
|
||||||
|
selectedType === 'OUT' ? 'bg-red-500 text-white' : '',
|
||||||
|
selectedType === 'ADJUSTMENT' ? 'bg-yellow-500 text-white' : ''
|
||||||
|
]">
|
||||||
|
<span class="text-2xl font-bold">
|
||||||
|
{{ selectedType === 'OUT' ? '-' : selectedType === 'IN' ? '+' : '' }}{{ quantity }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button @click="quantity = String(parseFloat(quantity) + 1)" class="w-12 h-12 bg-slate-100 dark:bg-slate-700 rounded-lg text-xl font-bold text-slate-700 dark:text-slate-300 active:bg-slate-200 dark:active:bg-slate-600">+</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Reason + Note Button (Combined Row) -->
|
<!-- Reason + Note Button (Combined Row) -->
|
||||||
@@ -1065,19 +1174,13 @@ export default {
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Buttons Row -->
|
<!-- Buttons -->
|
||||||
<div class="flex gap-2">
|
<div class="space-y-2">
|
||||||
<button
|
|
||||||
@click="cancelScan"
|
|
||||||
class="flex-1 py-3 font-medium rounded-xl bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-300 transition active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
@click="submitMovement"
|
@click="submitMovement"
|
||||||
:disabled="!canSubmit"
|
:disabled="!canSubmit"
|
||||||
:class="[
|
:class="[
|
||||||
'flex-[2] py-3 font-bold rounded-xl text-white transition active:scale-[0.98] disabled:opacity-50 disabled:active:scale-100',
|
'w-full py-5 text-lg font-bold rounded-xl text-white transition active:scale-[0.98] disabled:opacity-50 disabled:active:scale-100',
|
||||||
selectedType === 'IN' ? 'bg-green-600' : '',
|
selectedType === 'IN' ? 'bg-green-600' : '',
|
||||||
selectedType === 'OUT' ? 'bg-red-600' : '',
|
selectedType === 'OUT' ? 'bg-red-600' : '',
|
||||||
selectedType === 'ADJUSTMENT' ? 'bg-yellow-600' : ''
|
selectedType === 'ADJUSTMENT' ? 'bg-yellow-600' : ''
|
||||||
@@ -1085,6 +1188,12 @@ export default {
|
|||||||
>
|
>
|
||||||
{{ isLoading ? 'Speichert...' : (selectedType === 'IN' ? 'Einbuchen' : selectedType === 'OUT' ? 'Ausbuchen' : 'Korrigieren') }}
|
{{ isLoading ? 'Speichert...' : (selectedType === 'IN' ? 'Einbuchen' : selectedType === 'OUT' ? 'Ausbuchen' : 'Korrigieren') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
@click="cancelScan"
|
||||||
|
class="w-full py-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1163,6 +1272,149 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ORDERS TAB -->
|
||||||
|
<div v-else-if="currentTab === 'orders'" class="p-4">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="isLoadingOrders" class="space-y-3">
|
||||||
|
<div v-for="i in 3" :key="i" class="bg-white dark:bg-slate-800 p-4 rounded-xl shadow-sm animate-pulse">
|
||||||
|
<div class="h-5 bg-slate-200 dark:bg-slate-700 rounded w-1/2 mb-3"></div>
|
||||||
|
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded w-3/4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Order List (when no order selected) -->
|
||||||
|
<template v-else-if="!selectedOrder">
|
||||||
|
<div v-if="pendingOrders.length === 0" class="text-center py-12">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto text-slate-300 dark:text-slate-600 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-slate-500 dark:text-slate-400 text-lg font-medium">Keine offenen Bestellungen</p>
|
||||||
|
<p class="text-slate-400 dark:text-slate-500 text-sm mt-1">Alle Lieferungen wurden empfangen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">{{ pendingOrders.length }} Bestellung(en) warten auf Wareneingang:</p>
|
||||||
|
<div
|
||||||
|
v-for="order in pendingOrders"
|
||||||
|
:key="order.id"
|
||||||
|
@click="selectOrderForReceiving(order)"
|
||||||
|
class="bg-white dark:bg-slate-800 p-4 rounded-xl shadow-sm cursor-pointer hover:shadow-md active:scale-[0.98] transition"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="font-bold text-slate-800 dark:text-white">{{ order.orderNumber }}</span>
|
||||||
|
<span :class="[
|
||||||
|
'px-2 py-0.5 text-xs font-medium rounded',
|
||||||
|
order.status === 'sent' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||||
|
]">
|
||||||
|
{{ order.statusLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-slate-600 dark:text-slate-300">{{ order.distributorName }}</p>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
{{ order.positionCount }} Position(en) · {{ order.totalItems }} Artikel gesamt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-xs text-slate-400">{{ order.create }}</p>
|
||||||
|
<p v-if="order.daysSinceSent > 0" :class="['text-xs mt-1', order.daysSinceSent > 7 ? 'text-red-500 font-medium' : 'text-slate-400']">
|
||||||
|
{{ order.daysSinceSent }} Tag(e)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex items-center gap-2 text-primary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium">Wareneingang erfassen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Order Receiving Form (when order selected) -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 p-4 rounded-xl shadow-sm mb-4">
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-lg text-slate-800 dark:text-white">{{ selectedOrder.orderNumber }}</h3>
|
||||||
|
<p class="text-slate-600 dark:text-slate-300">{{ selectedOrder.distributorName }}</p>
|
||||||
|
</div>
|
||||||
|
<button @click="cancelOrderReceiving" class="p-2 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">Bestellt: {{ selectedOrder.create }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="flex gap-2 mb-4">
|
||||||
|
<button @click="setAllReceivingQty" class="flex-1 py-2 px-3 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-lg text-sm font-medium transition active:scale-[0.98]">
|
||||||
|
Alle übernehmen
|
||||||
|
</button>
|
||||||
|
<button @click="clearAllReceivingQty" class="flex-1 py-2 px-3 bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 rounded-lg text-sm font-medium transition active:scale-[0.98]">
|
||||||
|
Alle löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Positions -->
|
||||||
|
<div class="space-y-3 mb-4">
|
||||||
|
<div
|
||||||
|
v-for="(pos, index) in orderPositions"
|
||||||
|
:key="pos.articleId"
|
||||||
|
class="bg-white dark:bg-slate-800 p-4 rounded-xl shadow-sm"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between mb-3">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-slate-800 dark:text-white truncate">{{ pos.articleTitle }}</p>
|
||||||
|
<p class="text-xs text-slate-500 dark:text-slate-400">{{ pos.articleNumber }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right text-sm text-slate-500 dark:text-slate-400 ml-2">
|
||||||
|
<p>Bestellt: {{ pos.orderedQty }}</p>
|
||||||
|
<p v-if="pos.deliveredQty > 0" class="text-green-600 dark:text-green-400">Erhalten: {{ pos.deliveredQty }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-slate-500 dark:text-slate-400 w-20">Empfangen:</span>
|
||||||
|
<div class="flex items-center gap-2 flex-1">
|
||||||
|
<button
|
||||||
|
@click="orderPositions[index].receivingQty = Math.max(0, pos.receivingQty - 1)"
|
||||||
|
class="w-10 h-10 bg-slate-100 dark:bg-slate-700 rounded-lg text-lg font-bold text-slate-600 dark:text-slate-300 active:bg-slate-200 dark:active:bg-slate-600"
|
||||||
|
>-</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="orderPositions[index].receivingQty"
|
||||||
|
min="0"
|
||||||
|
:max="pos.remainingQty"
|
||||||
|
class="flex-1 text-center py-2 px-3 bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg text-lg font-bold text-slate-800 dark:text-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="orderPositions[index].receivingQty = Math.min(pos.remainingQty, pos.receivingQty + 1)"
|
||||||
|
class="w-10 h-10 bg-slate-100 dark:bg-slate-700 rounded-lg text-lg font-bold text-slate-600 dark:text-slate-300 active:bg-slate-200 dark:active:bg-slate-600"
|
||||||
|
>+</button>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-slate-400 w-16 text-right">/ {{ pos.remainingQty }} {{ pos.unit }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit -->
|
||||||
|
<div class="sticky bottom-0 bg-slate-50 dark:bg-slate-900 -mx-4 -mb-4 p-4 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<button
|
||||||
|
@click="submitOrderReceiving"
|
||||||
|
:disabled="isSubmittingOrder"
|
||||||
|
class="w-full py-4 bg-green-600 text-white text-lg font-bold rounded-xl transition active:scale-[0.98] disabled:opacity-50 disabled:active:scale-100"
|
||||||
|
>
|
||||||
|
{{ isSubmittingOrder ? 'Wird gespeichert...' : 'Wareneingang buchen' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Keypad -->
|
<!-- Keypad -->
|
||||||
|
|||||||
282
public/mobile/modules/lager/shippingnote/DatePicker.js
Normal file
282
public/mobile/modules/lager/shippingnote/DatePicker.js
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* DatePicker Component
|
||||||
|
*
|
||||||
|
* Beautiful mobile date picker with bottom sheet modal.
|
||||||
|
* Features quick buttons (Heute, Gestern) and calendar grid.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'DatePicker',
|
||||||
|
emits: ['update:modelValue', 'close'],
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const { ref, computed, watch } = Vue;
|
||||||
|
|
||||||
|
// Current calendar view month/year
|
||||||
|
const viewDate = ref(new Date());
|
||||||
|
|
||||||
|
// Initialize view date when opened
|
||||||
|
watch(() => props.show, (newVal) => {
|
||||||
|
if (newVal && props.modelValue) {
|
||||||
|
viewDate.value = new Date(props.modelValue);
|
||||||
|
} else if (newVal) {
|
||||||
|
viewDate.value = new Date();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// German weekday names (short)
|
||||||
|
const weekDays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||||
|
|
||||||
|
// German month names
|
||||||
|
const monthNames = [
|
||||||
|
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||||
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
|
||||||
|
];
|
||||||
|
|
||||||
|
const shortMonthNames = [
|
||||||
|
'Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun',
|
||||||
|
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'
|
||||||
|
];
|
||||||
|
|
||||||
|
const weekDayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||||
|
|
||||||
|
// Format date for display
|
||||||
|
const formatDisplayDate = (dateStr) => {
|
||||||
|
if (!dateStr) return 'Datum wählen';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const dayName = weekDayNames[date.getDay()];
|
||||||
|
const day = date.getDate();
|
||||||
|
const month = shortMonthNames[date.getMonth()];
|
||||||
|
const year = date.getFullYear();
|
||||||
|
return `${dayName}, ${day}. ${month} ${year}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Current month/year display
|
||||||
|
const currentMonthYear = computed(() => {
|
||||||
|
const month = monthNames[viewDate.value.getMonth()];
|
||||||
|
const year = viewDate.value.getFullYear();
|
||||||
|
return `${month} ${year}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get calendar days for current view
|
||||||
|
const calendarDays = computed(() => {
|
||||||
|
const year = viewDate.value.getFullYear();
|
||||||
|
const month = viewDate.value.getMonth();
|
||||||
|
|
||||||
|
// First day of month
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
// Last day of month
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
|
||||||
|
// Day of week for first day (0=Sun, convert to 0=Mon)
|
||||||
|
let startDay = firstDay.getDay() - 1;
|
||||||
|
if (startDay < 0) startDay = 6;
|
||||||
|
|
||||||
|
const days = [];
|
||||||
|
|
||||||
|
// Add empty slots for days before first of month
|
||||||
|
for (let i = 0; i < startDay; i++) {
|
||||||
|
days.push({ day: null, date: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add days of month
|
||||||
|
for (let d = 1; d <= lastDay.getDate(); d++) {
|
||||||
|
const date = new Date(year, month, d);
|
||||||
|
const dateStr = formatDateISO(date);
|
||||||
|
days.push({
|
||||||
|
day: d,
|
||||||
|
date: dateStr,
|
||||||
|
isToday: isToday(date),
|
||||||
|
isSelected: dateStr === props.modelValue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if date is today
|
||||||
|
const isToday = (date) => {
|
||||||
|
const today = new Date();
|
||||||
|
return date.toDateString() === today.toDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format date as ISO string (YYYY-MM-DD)
|
||||||
|
const formatDateISO = (date) => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Quick date helpers
|
||||||
|
const getToday = () => formatDateISO(new Date());
|
||||||
|
const getYesterday = () => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - 1);
|
||||||
|
return formatDateISO(d);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
const prevMonth = () => {
|
||||||
|
viewDate.value = new Date(viewDate.value.getFullYear(), viewDate.value.getMonth() - 1, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextMonth = () => {
|
||||||
|
viewDate.value = new Date(viewDate.value.getFullYear(), viewDate.value.getMonth() + 1, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
const selectDate = (dateStr) => {
|
||||||
|
if (!dateStr) return;
|
||||||
|
emit('update:modelValue', dateStr);
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectToday = () => selectDate(getToday());
|
||||||
|
const selectYesterday = () => selectDate(getYesterday());
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
viewDate,
|
||||||
|
weekDays,
|
||||||
|
currentMonthYear,
|
||||||
|
calendarDays,
|
||||||
|
formatDisplayDate,
|
||||||
|
prevMonth,
|
||||||
|
nextMonth,
|
||||||
|
selectDate,
|
||||||
|
selectToday,
|
||||||
|
selectYesterday,
|
||||||
|
close,
|
||||||
|
getToday,
|
||||||
|
getYesterday
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
template: `
|
||||||
|
<!-- Bottom Sheet Modal -->
|
||||||
|
<teleport to="body">
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="show" class="fixed inset-0 z-50">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div class="absolute inset-0 bg-black/50" @click="close"></div>
|
||||||
|
|
||||||
|
<!-- Sheet -->
|
||||||
|
<transition name="slide-up-sheet">
|
||||||
|
<div v-if="show" class="absolute bottom-0 left-0 right-0 bg-white dark:bg-slate-800 rounded-t-2xl shadow-xl max-h-[80vh] overflow-hidden">
|
||||||
|
<!-- Handle -->
|
||||||
|
<div class="flex justify-center pt-2 pb-1">
|
||||||
|
<div class="w-10 h-1 bg-slate-300 dark:bg-slate-600 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="px-4 pb-3 border-b border-slate-100 dark:border-slate-700">
|
||||||
|
<h3 class="text-lg font-semibold text-slate-800 dark:text-white text-center">
|
||||||
|
Datum wählen
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Buttons -->
|
||||||
|
<div class="px-4 py-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
@click="selectToday"
|
||||||
|
:class="[
|
||||||
|
'flex-1 py-2.5 rounded-xl font-medium transition',
|
||||||
|
modelValue === getToday()
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
Heute
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="selectYesterday"
|
||||||
|
:class="[
|
||||||
|
'flex-1 py-2.5 rounded-xl font-medium transition',
|
||||||
|
modelValue === getYesterday()
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
Gestern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Month Navigation -->
|
||||||
|
<div class="px-4 py-2 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
@click="prevMonth"
|
||||||
|
class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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>
|
||||||
|
<span class="text-base font-semibold text-slate-800 dark:text-white">
|
||||||
|
{{ currentMonthYear }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
@click="nextMonth"
|
||||||
|
class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calendar Grid -->
|
||||||
|
<div class="px-4 pb-6">
|
||||||
|
<!-- Weekday Headers -->
|
||||||
|
<div class="grid grid-cols-7 gap-1 mb-2">
|
||||||
|
<div
|
||||||
|
v-for="day in weekDays"
|
||||||
|
:key="day"
|
||||||
|
class="h-8 flex items-center justify-center text-xs font-medium text-slate-500 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
{{ day }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Days Grid -->
|
||||||
|
<div class="grid grid-cols-7 gap-1">
|
||||||
|
<button
|
||||||
|
v-for="(item, idx) in calendarDays"
|
||||||
|
:key="idx"
|
||||||
|
@click="selectDate(item.date)"
|
||||||
|
:disabled="!item.day"
|
||||||
|
:class="[
|
||||||
|
'h-10 flex items-center justify-center rounded-full text-sm font-medium transition',
|
||||||
|
!item.day ? 'invisible' : '',
|
||||||
|
item.isSelected ? 'bg-primary text-white' : '',
|
||||||
|
item.isToday && !item.isSelected ? 'ring-2 ring-primary text-primary' : '',
|
||||||
|
!item.isSelected && !item.isToday && item.day ? 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700' : ''
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ item.day }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Safe area padding for iOS -->
|
||||||
|
<div class="h-6"></div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</teleport>
|
||||||
|
`
|
||||||
|
};
|
||||||
189
public/mobile/modules/lager/shippingnote/EmployeeSelector.js
Normal file
189
public/mobile/modules/lager/shippingnote/EmployeeSelector.js
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* EmployeeSelector Component
|
||||||
|
*
|
||||||
|
* Bottom sheet modal for searching and selecting employees.
|
||||||
|
* Supports lazy word search (e.g., "fab her" matches "Fabian Herbst").
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'EmployeeSelector',
|
||||||
|
emits: ['select', 'close'],
|
||||||
|
props: {
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
excludeIds: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const { ref, computed, watch } = Vue;
|
||||||
|
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const employees = ref([]);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const searchTimeout = ref(null);
|
||||||
|
|
||||||
|
// Filter out already selected employees
|
||||||
|
const filteredEmployees = computed(() => {
|
||||||
|
return employees.value.filter(emp => !props.excludeIds.includes(emp.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search employees when query changes
|
||||||
|
watch(searchQuery, (newVal) => {
|
||||||
|
if (searchTimeout.value) {
|
||||||
|
clearTimeout(searchTimeout.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce search
|
||||||
|
searchTimeout.value = setTimeout(() => {
|
||||||
|
searchEmployees(newVal);
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load employees when modal opens
|
||||||
|
watch(() => props.show, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
searchQuery.value = '';
|
||||||
|
searchEmployees('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchEmployees = async (query) => {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query) params.append('query', query);
|
||||||
|
|
||||||
|
const response = await fetch(`/MobileApp/Lager/ShippingNote/searchEmployees?${params}`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
employees.value = data.employees;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching employees:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectEmployee = (employee) => {
|
||||||
|
emit('select', employee);
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchQuery,
|
||||||
|
filteredEmployees,
|
||||||
|
isLoading,
|
||||||
|
selectEmployee,
|
||||||
|
close
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
template: `
|
||||||
|
<!-- Bottom Sheet Modal -->
|
||||||
|
<teleport to="body">
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="show" class="fixed inset-0 z-50">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div class="absolute inset-0 bg-black/50" @click="close"></div>
|
||||||
|
|
||||||
|
<!-- Sheet -->
|
||||||
|
<transition name="slide-up-sheet">
|
||||||
|
<div v-if="show" class="absolute bottom-0 left-0 right-0 bg-white dark:bg-slate-800 rounded-t-2xl shadow-xl max-h-[85vh] flex flex-col">
|
||||||
|
<!-- Handle -->
|
||||||
|
<div class="flex justify-center pt-2 pb-1 flex-shrink-0">
|
||||||
|
<div class="w-10 h-1 bg-slate-300 dark:bg-slate-600 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="px-4 pb-3 border-b border-slate-100 dark:border-slate-700 flex-shrink-0">
|
||||||
|
<h3 class="text-lg font-semibold text-slate-800 dark:text-white text-center">
|
||||||
|
Mitarbeiter auswählen
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Input -->
|
||||||
|
<div class="px-4 py-3 flex-shrink-0">
|
||||||
|
<div class="relative">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Name suchen..."
|
||||||
|
class="w-full pl-10 pr-4 py-3 bg-slate-100 dark:bg-slate-700 rounded-xl text-slate-800 dark:text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
autofocus
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results List -->
|
||||||
|
<div class="flex-1 overflow-y-auto px-4 pb-6">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="isLoading" class="py-8 text-center">
|
||||||
|
<div class="animate-spin w-8 h-8 border-3 border-primary border-t-transparent rounded-full mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else-if="filteredEmployees.length === 0" class="py-8 text-center text-slate-500 dark:text-slate-400">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-3 text-slate-300 dark:text-slate-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
<p>Keine Mitarbeiter gefunden</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Employee List -->
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<button
|
||||||
|
v-for="emp in filteredEmployees"
|
||||||
|
:key="emp.id"
|
||||||
|
@click="selectEmployee(emp)"
|
||||||
|
class="w-full flex items-center p-3 bg-slate-50 dark:bg-slate-700/50 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-700 transition text-left"
|
||||||
|
>
|
||||||
|
<!-- Avatar -->
|
||||||
|
<div class="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span class="text-primary font-semibold text-sm">
|
||||||
|
{{ emp.name.charAt(0).toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="ml-3 flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-slate-800 dark:text-white truncate">
|
||||||
|
{{ emp.name }}
|
||||||
|
</p>
|
||||||
|
<p v-if="emp.email" class="text-sm text-slate-500 dark:text-slate-400 truncate">
|
||||||
|
{{ emp.email }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Select Icon -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Safe area padding for iOS -->
|
||||||
|
<div class="h-6 flex-shrink-0"></div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</teleport>
|
||||||
|
`
|
||||||
|
};
|
||||||
1090
public/mobile/modules/lager/shippingnote/ShippingNoteForm.js
Normal file
1090
public/mobile/modules/lager/shippingnote/ShippingNoteForm.js
Normal file
File diff suppressed because it is too large
Load Diff
227
public/mobile/modules/lager/shippingnote/ShippingNoteList.js
Normal file
227
public/mobile/modules/lager/shippingnote/ShippingNoteList.js
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* ShippingNoteList Component
|
||||||
|
*
|
||||||
|
* Lists unsigned shipping notes for the current user.
|
||||||
|
* Features:
|
||||||
|
* - Pull to refresh
|
||||||
|
* - Tap to open signature modal
|
||||||
|
* - Shows customer, date, note preview
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { shippingNoteApi } from '/mobile/modules/lager/shippingnote/ShippingNoteModule.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ShippingNoteList',
|
||||||
|
emits: ['sign', 'toast'],
|
||||||
|
props: {
|
||||||
|
user: Object
|
||||||
|
},
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const { ref, onMounted } = Vue;
|
||||||
|
|
||||||
|
// Data
|
||||||
|
const shippingNotes = ref([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const refreshing = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
|
||||||
|
// Load shipping notes
|
||||||
|
const loadShippingNotes = async (isRefresh = false) => {
|
||||||
|
if (isRefresh) {
|
||||||
|
refreshing.value = true;
|
||||||
|
} else {
|
||||||
|
loading.value = true;
|
||||||
|
}
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await shippingNoteApi.get('getMyShippingNotes');
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
shippingNotes.value = data.shippingNotes || [];
|
||||||
|
} else {
|
||||||
|
error.value = data.error || 'Fehler beim Laden';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load shipping notes:', e);
|
||||||
|
error.value = 'Netzwerkfehler';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
refreshing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pull to refresh
|
||||||
|
let touchStartY = 0;
|
||||||
|
let isPulling = false;
|
||||||
|
|
||||||
|
const handleTouchStart = (e) => {
|
||||||
|
const scrollTop = e.currentTarget.scrollTop;
|
||||||
|
if (scrollTop === 0) {
|
||||||
|
touchStartY = e.touches[0].clientY;
|
||||||
|
isPulling = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e) => {
|
||||||
|
if (!isPulling) return;
|
||||||
|
const deltaY = e.touches[0].clientY - touchStartY;
|
||||||
|
if (deltaY > 80 && !refreshing.value) {
|
||||||
|
loadShippingNotes(true);
|
||||||
|
isPulling = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
isPulling = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open signature for a shipping note
|
||||||
|
const openSignature = (shippingNote) => {
|
||||||
|
emit('sign', shippingNote);
|
||||||
|
navigator.vibrate?.([50]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('de-AT', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
onMounted(() => {
|
||||||
|
loadShippingNotes();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
shippingNotes,
|
||||||
|
loading,
|
||||||
|
refreshing,
|
||||||
|
error,
|
||||||
|
loadShippingNotes,
|
||||||
|
handleTouchStart,
|
||||||
|
handleTouchMove,
|
||||||
|
handleTouchEnd,
|
||||||
|
openSignature,
|
||||||
|
formatDate
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
template: `
|
||||||
|
<div
|
||||||
|
class="h-full overflow-y-auto"
|
||||||
|
@touchstart="handleTouchStart"
|
||||||
|
@touchmove="handleTouchMove"
|
||||||
|
@touchend="handleTouchEnd"
|
||||||
|
>
|
||||||
|
<!-- Pull to refresh indicator -->
|
||||||
|
<div v-if="refreshing" class="flex items-center justify-center py-4">
|
||||||
|
<svg class="animate-spin h-6 w-6 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="loading && !refreshing" class="flex flex-col items-center justify-center py-16">
|
||||||
|
<svg class="animate-spin h-8 w-8 text-primary mb-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">Lade Lieferscheine...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-else-if="error" class="flex flex-col items-center justify-center py-16 px-4">
|
||||||
|
<div class="w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400 mb-4">{{ error }}</p>
|
||||||
|
<button
|
||||||
|
@click="loadShippingNotes()"
|
||||||
|
class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-else-if="shippingNotes.length === 0" class="flex flex-col items-center justify-center py-16 px-4">
|
||||||
|
<div class="w-16 h-16 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg font-medium text-slate-800 dark:text-white mb-1">Alles unterschrieben!</p>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400 text-center">
|
||||||
|
Keine offenen Lieferscheine zum Unterschreiben.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shipping note list -->
|
||||||
|
<div v-else class="p-3 space-y-2">
|
||||||
|
<div class="text-xs text-slate-500 dark:text-slate-400 mb-2 px-1">
|
||||||
|
{{ shippingNotes.length }} {{ shippingNotes.length === 1 ? 'Lieferschein' : 'Lieferscheine' }} zum Unterschreiben
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-for="note in shippingNotes"
|
||||||
|
:key="note.id"
|
||||||
|
@click="openSignature(note)"
|
||||||
|
class="w-full text-left bg-white dark:bg-slate-800 rounded-xl p-4 shadow-sm active:bg-slate-50 dark:active:bg-slate-700 transition"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<!-- Number and Date -->
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="text-sm font-semibold text-primary">
|
||||||
|
#{{ note.number || note.id }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-slate-400 dark:text-slate-500">
|
||||||
|
{{ formatDate(note.date) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Customer Name -->
|
||||||
|
<div class="font-medium text-slate-800 dark:text-white truncate">
|
||||||
|
{{ note.customerName || note.deliveryAddressName || 'Unbekannter Kunde' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Address -->
|
||||||
|
<div class="text-sm text-slate-500 dark:text-slate-400 truncate mt-0.5">
|
||||||
|
{{ note.deliveryAddressLine }}, {{ note.deliveryAddressPLZ }} {{ note.deliveryAddressCity }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note preview -->
|
||||||
|
<div v-if="note.note" class="text-xs text-slate-400 dark:text-slate-500 mt-2 truncate">
|
||||||
|
{{ note.note }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sign indicator -->
|
||||||
|
<div class="flex-shrink-0 ml-3">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Refresh hint -->
|
||||||
|
<div class="text-center py-4 text-xs text-slate-400 dark:text-slate-500">
|
||||||
|
Ziehen zum Aktualisieren
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
};
|
||||||
164
public/mobile/modules/lager/shippingnote/ShippingNoteModule.js
Normal file
164
public/mobile/modules/lager/shippingnote/ShippingNoteModule.js
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { createModuleApi } from '/mobile/shared/api.js';
|
||||||
|
import ShippingNoteForm from '/mobile/modules/lager/shippingnote/ShippingNoteForm.js';
|
||||||
|
import ShippingNoteList from '/mobile/modules/lager/shippingnote/ShippingNoteList.js';
|
||||||
|
import SignaturePad from '/mobile/modules/lager/shippingnote/SignaturePad.js';
|
||||||
|
|
||||||
|
const shippingNoteApi = createModuleApi('Lager/ShippingNote');
|
||||||
|
|
||||||
|
export { shippingNoteApi };
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ShippingNoteModule',
|
||||||
|
emits: ['navigate', 'toast'],
|
||||||
|
props: {
|
||||||
|
user: Object,
|
||||||
|
submodule: String
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
ShippingNoteForm,
|
||||||
|
ShippingNoteList,
|
||||||
|
SignaturePad
|
||||||
|
},
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const { ref, computed, watch, onMounted } = Vue;
|
||||||
|
|
||||||
|
// Current view: 'create' | 'list' | 'sign'
|
||||||
|
const currentTab = ref('create');
|
||||||
|
|
||||||
|
// Signature modal state
|
||||||
|
const showSignatureModal = ref(false);
|
||||||
|
const signatureShippingNoteId = ref(null);
|
||||||
|
const signatureShippingNote = ref(null);
|
||||||
|
|
||||||
|
// Last created shipping note (for immediate signing)
|
||||||
|
const lastCreatedId = ref(null);
|
||||||
|
|
||||||
|
// Open signature modal for a shipping note
|
||||||
|
const openSignature = (shippingNote) => {
|
||||||
|
signatureShippingNoteId.value = shippingNote.id;
|
||||||
|
signatureShippingNote.value = shippingNote;
|
||||||
|
showSignatureModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close signature modal
|
||||||
|
const closeSignature = () => {
|
||||||
|
showSignatureModal.value = false;
|
||||||
|
signatureShippingNoteId.value = null;
|
||||||
|
signatureShippingNote.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle successful signature
|
||||||
|
const handleSignatureComplete = () => {
|
||||||
|
closeSignature();
|
||||||
|
emit('toast', 'Unterschrift gespeichert', 'success');
|
||||||
|
// Haptic feedback
|
||||||
|
navigator.vibrate?.([100, 50, 100]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle shipping note created
|
||||||
|
const handleCreated = (shippingNote) => {
|
||||||
|
lastCreatedId.value = shippingNote.id;
|
||||||
|
emit('toast', 'Lieferschein erstellt', 'success');
|
||||||
|
// Haptic feedback
|
||||||
|
navigator.vibrate?.([100]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle immediate sign after create
|
||||||
|
const handleCreateAndSign = (shippingNote) => {
|
||||||
|
handleCreated(shippingNote);
|
||||||
|
// Open signature modal immediately
|
||||||
|
openSignature(shippingNote);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show toast
|
||||||
|
const showToast = (message, type) => {
|
||||||
|
emit('toast', message, type);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Switch tab
|
||||||
|
const switchTab = (tab) => {
|
||||||
|
currentTab.value = tab;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentTab,
|
||||||
|
showSignatureModal,
|
||||||
|
signatureShippingNoteId,
|
||||||
|
signatureShippingNote,
|
||||||
|
lastCreatedId,
|
||||||
|
openSignature,
|
||||||
|
closeSignature,
|
||||||
|
handleSignatureComplete,
|
||||||
|
handleCreated,
|
||||||
|
handleCreateAndSign,
|
||||||
|
showToast,
|
||||||
|
switchTab
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
template: `
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="flex bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 px-3 py-2">
|
||||||
|
<button
|
||||||
|
@click="switchTab('create')"
|
||||||
|
:class="[
|
||||||
|
'flex-1 py-2.5 text-sm font-medium rounded-lg transition flex items-center justify-center gap-2',
|
||||||
|
currentTab === 'create' ? 'bg-white dark:bg-slate-800 shadow-sm text-primary' : 'text-slate-500 dark:text-slate-400'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Neu erstellen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="switchTab('list')"
|
||||||
|
:class="[
|
||||||
|
'flex-1 py-2.5 text-sm font-medium rounded-lg transition flex items-center justify-center gap-2 ml-1',
|
||||||
|
currentTab === 'list' ? 'bg-white dark:bg-slate-800 shadow-sm text-primary' : 'text-slate-500 dark:text-slate-400'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
Unterschreiben
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<main class="flex-1 overflow-y-auto bg-slate-50 dark:bg-slate-900">
|
||||||
|
<!-- Create Form -->
|
||||||
|
<ShippingNoteForm
|
||||||
|
v-if="currentTab === 'create'"
|
||||||
|
:user="user"
|
||||||
|
@created="handleCreated"
|
||||||
|
@createAndSign="handleCreateAndSign"
|
||||||
|
@toast="showToast"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- List of unsigned notes -->
|
||||||
|
<ShippingNoteList
|
||||||
|
v-else-if="currentTab === 'list'"
|
||||||
|
:user="user"
|
||||||
|
@sign="openSignature"
|
||||||
|
@toast="showToast"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Signature Modal -->
|
||||||
|
<transition name="slide-up">
|
||||||
|
<div v-if="showSignatureModal" class="fixed inset-0 z-50 flex flex-col bg-white dark:bg-slate-900">
|
||||||
|
<SignaturePad
|
||||||
|
:shipping-note-id="signatureShippingNoteId"
|
||||||
|
:shipping-note="signatureShippingNote"
|
||||||
|
@close="closeSignature"
|
||||||
|
@signed="handleSignatureComplete"
|
||||||
|
@toast="showToast"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
};
|
||||||
300
public/mobile/modules/lager/shippingnote/SignaturePad.js
Normal file
300
public/mobile/modules/lager/shippingnote/SignaturePad.js
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* SignaturePad Component
|
||||||
|
*
|
||||||
|
* Full-screen signature capture for shipping notes.
|
||||||
|
* Features:
|
||||||
|
* - Canvas-based signature drawing
|
||||||
|
* - Customer name input
|
||||||
|
* - Clear/retry functionality
|
||||||
|
* - Base64 PNG export
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { shippingNoteApi } from '/mobile/modules/lager/shippingnote/ShippingNoteModule.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'SignaturePad',
|
||||||
|
emits: ['close', 'signed', 'toast'],
|
||||||
|
props: {
|
||||||
|
shippingNoteId: [Number, String],
|
||||||
|
shippingNote: Object
|
||||||
|
},
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const { ref, onMounted, onUnmounted, nextTick } = Vue;
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const canvasRef = ref(null);
|
||||||
|
const signatureName = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const hasSignature = ref(false);
|
||||||
|
|
||||||
|
// Canvas context
|
||||||
|
let ctx = null;
|
||||||
|
let isDrawing = false;
|
||||||
|
let lastX = 0;
|
||||||
|
let lastY = 0;
|
||||||
|
|
||||||
|
// Initialize canvas
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick();
|
||||||
|
initCanvas();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
const initCanvas = () => {
|
||||||
|
const canvas = canvasRef.value;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
// Set canvas size to container
|
||||||
|
const container = canvas.parentElement;
|
||||||
|
canvas.width = container.clientWidth;
|
||||||
|
canvas.height = container.clientHeight;
|
||||||
|
|
||||||
|
ctx = canvas.getContext('2d');
|
||||||
|
ctx.strokeStyle = '#000000';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
|
||||||
|
// Fill with white background
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
// Save current signature
|
||||||
|
const canvas = canvasRef.value;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const imageData = canvas.toDataURL();
|
||||||
|
|
||||||
|
// Resize canvas
|
||||||
|
initCanvas();
|
||||||
|
|
||||||
|
// Restore signature
|
||||||
|
if (hasSignature.value) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
};
|
||||||
|
img.src = imageData;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get position from touch/mouse event
|
||||||
|
const getPosition = (e) => {
|
||||||
|
const canvas = canvasRef.value;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (e.touches && e.touches.length > 0) {
|
||||||
|
return {
|
||||||
|
x: e.touches[0].clientX - rect.left,
|
||||||
|
y: e.touches[0].clientY - rect.top
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x: e.clientX - rect.left,
|
||||||
|
y: e.clientY - rect.top
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start drawing
|
||||||
|
const startDrawing = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isDrawing = true;
|
||||||
|
const pos = getPosition(e);
|
||||||
|
lastX = pos.x;
|
||||||
|
lastY = pos.y;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Draw
|
||||||
|
const draw = (e) => {
|
||||||
|
if (!isDrawing) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const pos = getPosition(e);
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(lastX, lastY);
|
||||||
|
ctx.lineTo(pos.x, pos.y);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
lastX = pos.x;
|
||||||
|
lastY = pos.y;
|
||||||
|
hasSignature.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stop drawing
|
||||||
|
const stopDrawing = () => {
|
||||||
|
isDrawing = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
const clearCanvas = () => {
|
||||||
|
const canvas = canvasRef.value;
|
||||||
|
if (!canvas || !ctx) return;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
hasSignature.value = false;
|
||||||
|
navigator.vibrate?.([30]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Submit signature
|
||||||
|
const submitSignature = async () => {
|
||||||
|
if (!hasSignature.value) {
|
||||||
|
emit('toast', 'Bitte unterschreiben', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signatureName.value.trim()) {
|
||||||
|
emit('toast', 'Bitte Namen eingeben', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const canvas = canvasRef.value;
|
||||||
|
const signatureData = canvas.toDataURL('image/png');
|
||||||
|
|
||||||
|
const data = await shippingNoteApi.post(`sign?id=${props.shippingNoteId}`, {
|
||||||
|
signature: signatureData,
|
||||||
|
signatureName: signatureName.value.trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
emit('signed', data);
|
||||||
|
} else {
|
||||||
|
emit('toast', data.error || 'Fehler beim Speichern', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Signature submit failed:', e);
|
||||||
|
emit('toast', 'Netzwerkfehler', 'error');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close handler
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
canvasRef,
|
||||||
|
signatureName,
|
||||||
|
loading,
|
||||||
|
hasSignature,
|
||||||
|
startDrawing,
|
||||||
|
draw,
|
||||||
|
stopDrawing,
|
||||||
|
clearCanvas,
|
||||||
|
submitSignature,
|
||||||
|
handleClose
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
template: `
|
||||||
|
<div class="flex flex-col h-full bg-white dark:bg-slate-900">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800">
|
||||||
|
<button
|
||||||
|
@click="handleClose"
|
||||||
|
class="p-2 -ml-2 text-slate-500"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h2 class="text-lg font-semibold text-slate-800 dark:text-white">Unterschrift</h2>
|
||||||
|
<button
|
||||||
|
@click="clearCanvas"
|
||||||
|
class="p-2 -mr-2 text-red-500"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shipping Note Info -->
|
||||||
|
<div v-if="shippingNote" class="px-4 py-3 bg-slate-100 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<div class="text-sm text-slate-500 dark:text-slate-400">Lieferschein Nr.</div>
|
||||||
|
<div class="font-medium text-slate-800 dark:text-white">{{ shippingNote.number || shippingNote.id }}</div>
|
||||||
|
<div v-if="shippingNote.customerName" class="text-sm text-slate-600 dark:text-slate-300 mt-1">
|
||||||
|
{{ shippingNote.customerName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Signature Canvas -->
|
||||||
|
<div class="flex-1 relative bg-white overflow-hidden">
|
||||||
|
<canvas
|
||||||
|
ref="canvasRef"
|
||||||
|
class="absolute inset-0 touch-none"
|
||||||
|
@mousedown="startDrawing"
|
||||||
|
@mousemove="draw"
|
||||||
|
@mouseup="stopDrawing"
|
||||||
|
@mouseleave="stopDrawing"
|
||||||
|
@touchstart="startDrawing"
|
||||||
|
@touchmove="draw"
|
||||||
|
@touchend="stopDrawing"
|
||||||
|
@touchcancel="stopDrawing"
|
||||||
|
></canvas>
|
||||||
|
|
||||||
|
<!-- Signature line hint -->
|
||||||
|
<div class="absolute bottom-16 left-8 right-8 border-b-2 border-dashed border-slate-300 pointer-events-none"></div>
|
||||||
|
<div class="absolute bottom-12 left-8 text-xs text-slate-400 pointer-events-none">Hier unterschreiben</div>
|
||||||
|
|
||||||
|
<!-- Empty state hint -->
|
||||||
|
<div v-if="!hasSignature" class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div class="text-center text-slate-400">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm">Mit Finger unterschreiben</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="p-4 bg-slate-50 dark:bg-slate-800 border-t border-slate-200 dark:border-slate-700 space-y-3">
|
||||||
|
<!-- Name input -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-slate-500 dark:text-slate-400 mb-1">Name des Unterzeichners *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="signatureName"
|
||||||
|
class="w-full px-4 py-3 bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-xl text-base"
|
||||||
|
placeholder="Max Mustermann"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit button -->
|
||||||
|
<button
|
||||||
|
@click="submitSignature"
|
||||||
|
:disabled="!hasSignature || loading"
|
||||||
|
:class="[
|
||||||
|
'w-full py-5 rounded-xl text-base font-semibold transition flex items-center justify-center gap-2',
|
||||||
|
hasSignature && !loading
|
||||||
|
? 'bg-gradient-to-r from-green-500 to-emerald-600 text-white shadow-lg active:scale-[0.98]'
|
||||||
|
: 'bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<svg v-if="loading" class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
{{ loading ? 'Wird gespeichert...' : 'Unterschrift speichern' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
};
|
||||||
1373
public/mobile/modules/workorder/WorkorderModule.js
Normal file
1373
public/mobile/modules/workorder/WorkorderModule.js
Normal file
File diff suppressed because it is too large
Load Diff
18
public/mobile/shared/api.js
Normal file
18
public/mobile/shared/api.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export function createModuleApi(modulePath) {
|
||||||
|
return {
|
||||||
|
get: (endpoint) => fetch(`/MobileApp/${modulePath}/${endpoint}`).then(r => r.json()),
|
||||||
|
post: (endpoint, data) => fetch(`/MobileApp/${modulePath}/${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
}).then(r => r.json())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const debounce = (fn, delay) => {
|
||||||
|
let timeout;
|
||||||
|
return (...args) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => fn(...args), delay);
|
||||||
|
};
|
||||||
|
};
|
||||||
43
public/mobile/shared/useNumericKeypad.js
Normal file
43
public/mobile/shared/useNumericKeypad.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export function useNumericKeypad(initialValue = '1') {
|
||||||
|
const { ref } = Vue;
|
||||||
|
const quantity = ref(initialValue);
|
||||||
|
|
||||||
|
const appendDigit = (digit) => {
|
||||||
|
if (digit === '.' && quantity.value.includes('.')) return;
|
||||||
|
if (quantity.value === '0' && digit !== '.') {
|
||||||
|
quantity.value = digit;
|
||||||
|
} else {
|
||||||
|
quantity.value += digit;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteDigit = () => {
|
||||||
|
quantity.value = quantity.value.length > 1 ? quantity.value.slice(0, -1) : '0';
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearQuantity = () => {
|
||||||
|
quantity.value = '0';
|
||||||
|
};
|
||||||
|
|
||||||
|
const setQuantity = (value) => {
|
||||||
|
quantity.value = String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const increment = (amount = 1) => {
|
||||||
|
quantity.value = String(Math.max(1, parseFloat(quantity.value) + amount));
|
||||||
|
};
|
||||||
|
|
||||||
|
const decrement = (amount = 1) => {
|
||||||
|
quantity.value = String(Math.max(1, parseFloat(quantity.value) - amount));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
quantity,
|
||||||
|
appendDigit,
|
||||||
|
deleteDigit,
|
||||||
|
clearQuantity,
|
||||||
|
setQuantity,
|
||||||
|
increment,
|
||||||
|
decrement
|
||||||
|
};
|
||||||
|
}
|
||||||
134
scripts/geocode-addresses.php
Normal file
134
scripts/geocode-addresses.php
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Geocode Addresses Script
|
||||||
|
*
|
||||||
|
* Populates gps_lat and gps_long for addresses missing coordinates.
|
||||||
|
* Uses Google Geocoding API.
|
||||||
|
*
|
||||||
|
* Usage: php scripts/geocode-addresses.php [--limit=100] [--dry-run] [--verbose]
|
||||||
|
*/
|
||||||
|
|
||||||
|
require("../config/config.php");
|
||||||
|
|
||||||
|
define('FRONKDB_SQLDEBUG', false);
|
||||||
|
error_reporting(E_ALL & ~(E_NOTICE | E_STRICT | E_DEPRECATED));
|
||||||
|
|
||||||
|
require_once(LIBDIR . "/mvcfronk/mfRouter/mfRouter.php");
|
||||||
|
require_once(LIBDIR . "/mvcfronk/mfBase/mfBaseModel.php");
|
||||||
|
require_once(LIBDIR . "/mvcfronk/mfBase/mfBaseController.php");
|
||||||
|
|
||||||
|
// Parse command line arguments
|
||||||
|
$options = getopt('', ['limit::', 'dry-run', 'verbose']);
|
||||||
|
$limit = isset($options['limit']) ? intval($options['limit']) : 100;
|
||||||
|
$dryRun = isset($options['dry-run']);
|
||||||
|
$verbose = isset($options['verbose']);
|
||||||
|
|
||||||
|
// Check API key
|
||||||
|
if (!defined('TT_GEOCODING_API_SECRET') || empty(TT_GEOCODING_API_SECRET)) {
|
||||||
|
die("ERROR: TT_GEOCODING_API_SECRET is not defined in config.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiKey = TT_GEOCODING_API_SECRET;
|
||||||
|
|
||||||
|
// Get database connection
|
||||||
|
$db = FronkDB::singleton();
|
||||||
|
|
||||||
|
// Find addresses missing coordinates
|
||||||
|
$sql = "SELECT id, street, zip, city, country_id
|
||||||
|
FROM Address
|
||||||
|
WHERE (gps_lat IS NULL OR gps_lat = 0)
|
||||||
|
AND street != ''
|
||||||
|
AND street IS NOT NULL
|
||||||
|
AND city != ''
|
||||||
|
AND city IS NOT NULL
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT {$limit}";
|
||||||
|
|
||||||
|
$result = $db->query($sql);
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
die("ERROR: Database query failed.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = $db->num_rows($result);
|
||||||
|
echo "Found {$total} addresses to geocode" . ($dryRun ? " (DRY RUN)" : "") . "\n";
|
||||||
|
|
||||||
|
if ($total === 0) {
|
||||||
|
echo "Nothing to do.\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$success = 0;
|
||||||
|
$failed = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$id = $row['id'];
|
||||||
|
$street = $row['street'];
|
||||||
|
$zip = $row['zip'];
|
||||||
|
$city = $row['city'];
|
||||||
|
|
||||||
|
// Build address string
|
||||||
|
$addressParts = [];
|
||||||
|
if ($street) $addressParts[] = $street;
|
||||||
|
if ($zip) $addressParts[] = $zip;
|
||||||
|
if ($city) $addressParts[] = $city;
|
||||||
|
$addressParts[] = 'Austria'; // Default country
|
||||||
|
|
||||||
|
$addressString = implode(', ', $addressParts);
|
||||||
|
|
||||||
|
if ($verbose) {
|
||||||
|
echo "Processing ID {$id}: {$addressString}... ";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
if ($verbose) echo "SKIPPED (dry run)\n";
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call Google Geocoding API
|
||||||
|
$encodedAddress = urlencode($addressString);
|
||||||
|
$url = "https://maps.googleapis.com/maps/api/geocode/json?address={$encodedAddress}&key={$apiKey}®ion=at";
|
||||||
|
|
||||||
|
$response = @file_get_contents($url);
|
||||||
|
if (!$response) {
|
||||||
|
if ($verbose) echo "FAILED (API error)\n";
|
||||||
|
$failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
|
||||||
|
if ($data['status'] !== 'OK' || empty($data['results'])) {
|
||||||
|
if ($verbose) {
|
||||||
|
echo "FAILED (no results, status: {$data['status']})\n";
|
||||||
|
}
|
||||||
|
$failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$location = $data['results'][0]['geometry']['location'];
|
||||||
|
$lat = $location['lat'];
|
||||||
|
$lng = $location['lng'];
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
$db->query("UPDATE Address SET gps_lat = {$lat}, gps_long = {$lng} WHERE id = {$id}");
|
||||||
|
|
||||||
|
if ($verbose) {
|
||||||
|
echo "OK ({$lat}, {$lng})\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$success++;
|
||||||
|
|
||||||
|
// Rate limiting - 100ms between requests
|
||||||
|
usleep(100000);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n";
|
||||||
|
echo "Completed:\n";
|
||||||
|
echo " Success: {$success}\n";
|
||||||
|
echo " Failed: {$failed}\n";
|
||||||
|
echo " Skipped: {$skipped}\n";
|
||||||
|
echo " Total: {$total}\n";
|
||||||
Reference in New Issue
Block a user