Compare commits
151 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b24f433a5 | |||
| dcf50fb8af | |||
| cc4d56ae4c | |||
| 697a08e481 | |||
| 16078cc62b | |||
| 3a35d2181d | |||
| 6d2b5926bb | |||
| cc3bdccd9a | |||
| 3348b9f668 | |||
| d0c198118d | |||
| 2ab3f01d89 | |||
| 3981475c55 | |||
| acde42b406 | |||
| 7ca38aee70 | |||
| dd031551ca | |||
| f3cb4798d5 | |||
| 9cd0d5fb5e | |||
| 5ac369a643 | |||
| b2c97c1327 | |||
| 4ae044dace | |||
| c444f51423 | |||
| a201a95756 | |||
| b32169f433 | |||
| 023693c4c8 | |||
| 47bd728530 | |||
| 7e0ea10d68 | |||
| cd1965acc4 | |||
| 653f7dbeeb | |||
| 5506125b2e | |||
| e6ee697508 | |||
| bef14bcdcc | |||
| 8d0e5ffc1a | |||
| 680e8d8b95 | |||
| 13ba8556a4 | |||
| 46a2538bef | |||
| 57b9ec17de | |||
| af3b26a273 | |||
| 5da9003b14 | |||
| ba548583ba | |||
| 5c12a8af43 | |||
| 05210fea4b | |||
| f28201e711 | |||
| c23ec0cb53 | |||
| 42c7e59d63 | |||
| 192cf7d9cb | |||
| 3235cf21ba | |||
| 47ee14319d | |||
| 457d2a5161 | |||
| 1358aaa705 | |||
| d7bae268da | |||
| 9310397320 | |||
| ba81d7b6a8 | |||
| 676ff961ed | |||
| b40b58f866 | |||
| 9acbc101e1 | |||
| ccc6b827f0 | |||
| 0a2894b378 | |||
| 2d6930d38d | |||
| 11fbe847f2 | |||
| 72933ada6e | |||
| e10d58d231 | |||
| 4b39d491d2 | |||
| a1dbe41b6c | |||
| e233e609e6 | |||
| d64b718573 | |||
| 153dff6bc7 | |||
| 4fbced2c70 | |||
| 2970f48e65 | |||
| e137bec8dd | |||
| 3ca0cf23f1 | |||
| 02466eea14 | |||
| f4b590bc3b | |||
| 65bb13aa3b | |||
| 2332ad2216 | |||
| 8005e7a45b | |||
| f9ae0d142a | |||
| e81fdc24bb | |||
| 778de8ca14 | |||
| 00c37c0a37 | |||
| a56580ce27 | |||
| 11f886cd71 | |||
| ac07236936 | |||
| 9aa37b355e | |||
| 12ad7d36ff | |||
| c77a6ff811 | |||
| 0a82fca6d3 | |||
| 1e57e7c33e | |||
| c484948a5e | |||
| 4748b15cc4 | |||
| 9ccba70ede | |||
| 1868b3e248 | |||
| 9f2627faf8 | |||
| a7b1fb902c | |||
| 6afce2ef58 | |||
| fff01ff30f | |||
| a576a93a0b | |||
| a33426f3c2 | |||
| 0dc309642c | |||
| 8dc1a2dc3c | |||
| e477242f16 | |||
| f75156bc62 | |||
| e379f490a4 | |||
| c210ed7fac | |||
| 92b7f60c75 | |||
| 7e7c638ef1 | |||
| b19ef017d6 | |||
| 8bcbf3bd1d | |||
| 540d4814d9 | |||
| 884412f5a9 | |||
| 4814d24efb | |||
| 34095e0d38 | |||
| bbad25b55b | |||
| c76f4be928 | |||
| d89ff4ea7f | |||
| 7a7dba010e | |||
| b81282b42e | |||
| 617d3b281e | |||
| c52e92e3ce | |||
| a9e981baa0 | |||
| 8fdcc75826 | |||
| d82f6b6965 | |||
| 3a3711b713 | |||
| c1778b3d49 | |||
| 514c28b900 | |||
| 09a3295920 | |||
| 400737dab8 | |||
| 27f146b945 | |||
| fb9a2f11fe | |||
| 4e211c8834 | |||
| 79bef2775c | |||
| 3fd4f5080d | |||
| f677a1d61b | |||
| d5aafc88a9 | |||
| 52a4b99214 | |||
| ff4c9c6bf0 | |||
| 2756bbb2c2 | |||
| 6c99fa0401 | |||
| c38f44c182 | |||
| 167788ac15 | |||
| 2c64cca921 | |||
| 894b23166c | |||
| e3a5eeb50d | |||
| 5887ecb638 | |||
| 78c96f8bce | |||
| 5ddee59227 | |||
| 94992afbe3 | |||
| 717eaa6ed8 | |||
| 9fb78329cc | |||
| af9465c127 | |||
| 5c0b83a0a3 | |||
| b1da083557 |
@@ -23,3 +23,5 @@ docker-compose.yml
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
generated/mammon/
|
||||||
+4
-1
@@ -6,5 +6,8 @@ COPY . ./
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
ENV NGINX_ENVSUBST_OUTPUT_DIR=/usr/share/nginx/html
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
|
RUN mkdir etc/nginx/templates && \
|
||||||
|
mv /usr/share/nginx/html/index.html /etc/nginx/templates/index.html.template
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
+1195
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
server {
|
||||||
|
listen 80 http2;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ $uri.html /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80 http2;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ $uri.html /index.html;
|
|
||||||
}
|
|
||||||
location /marbas/ {
|
|
||||||
proxy_pass https://${MARBAS_URL}/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
rewrite /marbas/(.*) /$1 break;
|
|
||||||
proxy_ssl_server_name on;
|
|
||||||
proxy_set_header Host "${MARBAS_URL}";
|
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
|
||||||
proxy_set_header Accept-Encoding "";
|
|
||||||
sub_filter 'https://${MARBAS_URL}/' '/marbas/';
|
|
||||||
sub_filter 'http://${MARBAS_URL}/' '/marbas/';
|
|
||||||
sub_filter_once off;
|
|
||||||
sub_filter_types application/json;
|
|
||||||
}
|
|
||||||
location /pocketbase/ {
|
|
||||||
proxy_pass https://${POCKET_BASE_URL}/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
rewrite /pocketbase/(.*) /$1 break;
|
|
||||||
proxy_ssl_server_name on;
|
|
||||||
proxy_set_header Host "${POCKET_BASE_URL}";
|
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
|
||||||
}
|
|
||||||
location /evepraisal/ {
|
|
||||||
proxy_pass https://${FUZZWORK_URL}/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
rewrite /evepraisal/(.*) /$1 break;
|
|
||||||
proxy_ssl_server_name on;
|
|
||||||
proxy_set_header Host "${FUZZWORK_URL}";
|
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
|
||||||
}
|
|
||||||
location /fuzzwork/ {
|
|
||||||
proxy_pass https://${EVEPRAISAL_URL}/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
rewrite /fuzzwork/(.*) /$1 break;
|
|
||||||
proxy_ssl_server_name on;
|
|
||||||
proxy_set_header Host "${EVEPRAISAL_URL}";
|
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
|
||||||
}
|
|
||||||
location /esi/ {
|
|
||||||
proxy_pass https://esi.evetech.net/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
rewrite /esi/(.*) /latest/$1 break;
|
|
||||||
proxy_ssl_server_name on;
|
|
||||||
proxy_set_header Host "esi.evetech.net";
|
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
|
||||||
proxy_set_header User-Agent "${ESI_USER_AGENT}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
+2513
-1814
File diff suppressed because it is too large
Load Diff
+20
-16
@@ -6,30 +6,34 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host --debug",
|
"dev": "vite --host --debug",
|
||||||
"build": "vue-tsc && vite build",
|
"build": "vue-tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/vue": "^2.0.18",
|
"@heroicons/vue": "^2.0.18",
|
||||||
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
|
"@vueuse/components": "^14.3.0",
|
||||||
"@vueuse/components": "^10.5.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
"@vueuse/core": "^10.2.1",
|
"@vueuse/integrations": "^14.3.0",
|
||||||
"@vueuse/integrations": "^10.2.1",
|
"@vueuse/router": "^14.3.0",
|
||||||
"axios": "^1.4.0",
|
"axios": "^1.4.0",
|
||||||
|
"gemory": "file:",
|
||||||
"loglevel": "^1.8.1",
|
"loglevel": "^1.8.1",
|
||||||
"loglevel-plugin-prefix": "^0.8.4",
|
"loglevel-plugin-prefix": "^0.8.4",
|
||||||
"pinia": "^2.1.6",
|
"monaco-editor": "^0.55.1",
|
||||||
"pocketbase": "^0.18.0",
|
"pinia": "^3.0.4",
|
||||||
|
"sortablejs": "^1.15.7",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.3.4",
|
||||||
"vue-router": "^4.2.4"
|
"vue-router": "^5.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.4.5",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@vitejs/plugin-vue": "^4.2.3",
|
"@types/node": "^25.8.0",
|
||||||
"autoprefixer": "^10.4.14",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
"postcss": "^8.4.27",
|
"tailwindcss": "^4.3.0",
|
||||||
"tailwindcss": "^3.3.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript": "^5.0.2",
|
"vite": "^8.0.13",
|
||||||
"vite": "^4.4.5",
|
"vite-plugin-runtime-env": "^1.0.0",
|
||||||
"vue-tsc": "^1.8.5"
|
"vitest": "^4.1.6",
|
||||||
|
"vue-tsc": "^3.2.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
'postcss-import': {},
|
|
||||||
'tailwindcss/nesting': {},
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
+11
-2
@@ -1,13 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import {computed} from 'vue';
|
||||||
import {RouterView, useRoute} from 'vue-router';
|
import {RouterView, useRoute} from 'vue-router';
|
||||||
import {Sidebar} from './sidebar';
|
import {Sidebar} from './sidebar';
|
||||||
|
import {ConfirmModal} from '@/confirm';
|
||||||
|
import {routeNames} from '@/routes';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
|
const hideSidebar = computed(() => {
|
||||||
|
return route.name === routeNames.callback || route.name === routeNames.about;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="route.name === 'login'">
|
<template v-if="hideSidebar">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -16,9 +22,12 @@ const route = useRoute();
|
|||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<ConfirmModal />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
|
||||||
div.main-container {
|
div.main-container {
|
||||||
@apply px-4 sm:ml-64;
|
@apply px-4 sm:ml-64;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import {Character} from "./chartacters.ts";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
character: Character;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
size: 32
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex">
|
||||||
|
<img class="me-2" :src="`https://images.evetech.net/characters/${character.characterId}/portrait?size=${size}`" />
|
||||||
|
<span>{{ character.name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import {activityApi, characterApi} from "@/mammon";
|
||||||
|
import {defineStore} from "pinia";
|
||||||
|
import {ref} from "vue";
|
||||||
|
import {CharacterResponse} from "@/generated/mammon";
|
||||||
|
|
||||||
|
export type Character = CharacterResponse
|
||||||
|
|
||||||
|
export const useCharactersStore = defineStore('characters', () => {
|
||||||
|
const characters = ref<Character[]>([]);
|
||||||
|
|
||||||
|
const findById = async (characterId: number): Promise<Character | undefined> => {
|
||||||
|
let character = characters.value.find(c => c.characterId === characterId);
|
||||||
|
|
||||||
|
if (!character) {
|
||||||
|
await refresh(); // TODO call api instead of refresh
|
||||||
|
character = characters.value.find(c => c.characterId === characterId);
|
||||||
|
}
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadActivities = (characterId: number): Promise<void> => activityApi.fetchNewActivitiesForCharacter(characterId) as Promise<void>;
|
||||||
|
|
||||||
|
const refresh = () => characterApi.findAllCharacters().then(response => characters.value = response.data);
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
return {characters, findById, reloadActivities, refresh};
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './chartacters.ts'
|
||||||
|
|
||||||
|
export {default as CharacterLabel} from './CharacterLabel.vue';
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {ChevronDownIcon, ChevronUpIcon} from '@heroicons/vue/24/outline';
|
||||||
|
import {vOnClickOutside} from '@vueuse/components';
|
||||||
|
import {useElementBounding, useEventListener} from '@vueuse/core';
|
||||||
|
import {computed, ref} from 'vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
inline?: boolean;
|
||||||
|
autoClose?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
inline: false,
|
||||||
|
autoClose: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const root = ref<HTMLElement | null>(null);
|
||||||
|
const floating = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const { left, bottom, width } = useElementBounding(root);
|
||||||
|
|
||||||
|
const floatingStyle = computed(() => ({
|
||||||
|
left: `${left.value}px`,
|
||||||
|
top: `${bottom.value}px`,
|
||||||
|
minWidth: `${width.value}px`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const doAutoClose = () => {
|
||||||
|
if (props.autoClose) {
|
||||||
|
isOpen.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEventListener('keyup', e => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
doAutoClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="root" class="dropdown" :class="{'dropdown-open': isOpen, 'dropdown-close': !isOpen}" v-on-click-outside="[doAutoClose, { ignore: [floating] }]">
|
||||||
|
<button @click="isOpen = !isOpen" class="cursor-pointer">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-transform"
|
||||||
|
enter-from-class="rotate-180"
|
||||||
|
leave-active-class="hidden"
|
||||||
|
leave-to-class="rotate-180">
|
||||||
|
<ChevronDownIcon v-if="!isOpen" class="chevron" />
|
||||||
|
<ChevronUpIcon v-else class="chevron" />
|
||||||
|
</Transition>
|
||||||
|
<slot name="button" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-opacity"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
leave-from-class="transition-opacity"
|
||||||
|
leave-to-class="opacity-0">
|
||||||
|
<div v-if="inline && isOpen">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-opacity"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
leave-from-class="transition-opacity"
|
||||||
|
leave-to-class="opacity-0">
|
||||||
|
<div v-if="!inline && isOpen" ref="floating" class="dropdown-floating" :style="floatingStyle">
|
||||||
|
<div class="divide-y rounded-b-md">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
@apply w-4 h-4 me-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-floating {
|
||||||
|
@apply fixed z-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-floating > div {
|
||||||
|
@apply bg-slate-800 rounded-b-md shadow-lg;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+11
-25
@@ -1,24 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {vOnClickOutside} from '@vueuse/components';
|
import {vOnClickOutside} from '@vueuse/components';
|
||||||
import { useEventListener, useVModel } from '@vueuse/core';
|
import {useEventListener} from '@vueuse/core';
|
||||||
import {watch} from 'vue';
|
import {watch} from 'vue';
|
||||||
|
|
||||||
interface Props {
|
const open = defineModel('open', { default: false });
|
||||||
open: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emit {
|
watch(open, value => {
|
||||||
(e: 'update:open', value: boolean): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
open: false,
|
|
||||||
});
|
|
||||||
const emit = defineEmits<Emit>();
|
|
||||||
|
|
||||||
const isOpen = useVModel(props, 'open', emit, {passive: true});
|
|
||||||
|
|
||||||
watch(isOpen, value => {
|
|
||||||
if (value) {
|
if (value) {
|
||||||
document.body.classList.add('overflow-hidden');
|
document.body.classList.add('overflow-hidden');
|
||||||
} else {
|
} else {
|
||||||
@@ -27,18 +14,18 @@ watch(isOpen, value => {
|
|||||||
});
|
});
|
||||||
useEventListener('keyup', e => {
|
useEventListener('keyup', e => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
isOpen.value = false;
|
open.value = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
<template v-if="isOpen">
|
<template v-if="open">
|
||||||
<div class="fixed inset-0">
|
<div class="fixed inset-0 z-10">
|
||||||
<div class="absolute bg-black opacity-80 inset-0 z-0" />
|
<div class="absolute bg-black opacity-80 inset-0 z-0" />
|
||||||
<div class="absolute grid inset-0">
|
<div class="absolute grid inset-0">
|
||||||
<div class="justify-self-center m-auto" v-on-click-outside="() => isOpen = false">
|
<div class="justify-self-center m-auto" v-on-click-outside="() => open = false">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,14 +34,13 @@ useEventListener('keyup', e => {
|
|||||||
</Transition>
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
.fade-enter-from, .fade-leave-to {
|
.fade-enter-from, .fade-leave-to {
|
||||||
opacity: 0;
|
@apply opacity-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-enter-active, .fade-leave-active {
|
.fade-enter-active, .fade-leave-active {
|
||||||
transition: opacity 100ms ease-out;
|
@apply transition-opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const percentage = computed(() => (props.value / props.total) * 100);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full bg-gray-600 rounded-full h-2.5">
|
||||||
|
<div class="bg-emerald-600 h-2.5 rounded-full" :style="{ width: percentage + '%'}" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts" generic="T">
|
||||||
|
import {vOnClickOutside} from '@vueuse/components';
|
||||||
|
import {useVirtualList} from '@vueuse/core';
|
||||||
|
import {computed, ref} from 'vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: T[];
|
||||||
|
itemHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
itemHeight: 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelValue = defineModel<T>();
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const currentIndex = ref(-1);
|
||||||
|
const {list, scrollTo, containerProps, wrapperProps} = useVirtualList(computed(() => props.items), {
|
||||||
|
itemHeight: () => props.itemHeight,
|
||||||
|
overscan: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveDown = () => {
|
||||||
|
currentIndex.value = currentIndex.value >= props.items.length - 1 ? 0 : currentIndex.value + 1;
|
||||||
|
scrollTo(currentIndex.value);
|
||||||
|
};
|
||||||
|
const moveUp = () => {
|
||||||
|
currentIndex.value = currentIndex.value <= 0 ? props.items.length - 1 : currentIndex.value - 1;
|
||||||
|
scrollTo(currentIndex.value);
|
||||||
|
};
|
||||||
|
const select = (item?: T) => {
|
||||||
|
modelValue.value = item;
|
||||||
|
currentIndex.value = -1;
|
||||||
|
isOpen.value = false;
|
||||||
|
};
|
||||||
|
const submit = () => {
|
||||||
|
if (currentIndex.value >= 0 && currentIndex.value < props.items.length) {
|
||||||
|
select(props.items[currentIndex.value]);
|
||||||
|
} else if (modelValue.value === undefined && props.items.length > 0) {
|
||||||
|
select(props.items[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div @click="isOpen = true" v-on-click-outside="() => isOpen = false">
|
||||||
|
<div class="fake-input" @keyup.enter="submit" @keyup.down="moveDown" @keyup.up="moveUp">
|
||||||
|
<slot name="input" :value="modelValue" />
|
||||||
|
</div>
|
||||||
|
<div v-if="isOpen && items.length" class="z-20 absolute">
|
||||||
|
<div v-bind="containerProps" class="rounded-b" style="height: 300px">
|
||||||
|
<div v-bind="wrapperProps">
|
||||||
|
<div v-for="s in list" :key="s.index"
|
||||||
|
class="hover:bg-slate-700 cursor-pointer"
|
||||||
|
:class="s.index === currentIndex ? 'bg-emerald-500' : 'bg-slate-500'"
|
||||||
|
@click.stop="select(s.data)">
|
||||||
|
<slot name="item" :item="s.data" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
.fake-input {
|
||||||
|
@apply flex border bg-slate-500 rounded px-1 py-0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,30 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useVModel } from '@vueuse/core';
|
|
||||||
|
|
||||||
interface Props {
|
const modelValue = defineModel({ default: false });
|
||||||
modelValue?: boolean;
|
|
||||||
}
|
|
||||||
interface Emits {
|
|
||||||
(e: 'update:modelValue', value: boolean): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
modelValue: false
|
|
||||||
});
|
|
||||||
const emit = defineEmits<Emits>();
|
|
||||||
|
|
||||||
const value = useVModel(props, 'modelValue', emit);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<label class="flex items-center relative w-max cursor-pointer select-none">
|
<label class="flex items-center relative w-max cursor-pointer select-none">
|
||||||
<input type="checkbox" class="appearance-none transition-colors cursor-pointer w-10 h-5 rounded-full checked:bg-emerald-500 peer" v-model="value" />
|
<input type="checkbox" class="appearance-none transition-colors cursor-pointer w-10 h-5 rounded-full checked:bg-emerald-500 peer" v-model="modelValue" />
|
||||||
<span class="w-5 h-5 right-5 absolute rounded-full transform transition-transform bg-slate-100 peer-checked:bg-emerald-200" />
|
<span class="w-5 h-5 right-5 absolute rounded-full transform transition-transform bg-slate-100 peer-checked:bg-emerald-200" />
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
|
||||||
input:checked ~ span:last-child {
|
input:checked ~ span:last-child {
|
||||||
--tw-translate-x: 1.25rem;
|
transform: translateX(1.25rem);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { vElementHover } from '@vueuse/components';
|
|
||||||
import { useVModel } from '@vueuse/core';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
open: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emit {
|
|
||||||
(e: 'update:open', value: boolean): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
open: false,
|
|
||||||
});
|
|
||||||
const emit = defineEmits<Emit>();
|
|
||||||
|
|
||||||
const isOpen = useVModel(props, 'open', emit, {passive: true});
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div clas="flex flex-col items-center justify-center" :class="{'open': isOpen}">
|
|
||||||
<div v-element-hover="(h: boolean) => isOpen = h" class="m-auto header">
|
|
||||||
<slot name="header" />
|
|
||||||
</div>
|
|
||||||
<div v-if="isOpen" class="m-auto">
|
|
||||||
<div class="z-10 absolute">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
export { default as ClipboardButton } from './ClipboardButton.vue';
|
export { default as ClipboardButton } from './ClipboardButton.vue';
|
||||||
|
export { default as Dropdown } from './Dropdown.vue';
|
||||||
export { default as LoadingSpinner } from './LoadingSpinner.vue';
|
export { default as LoadingSpinner } from './LoadingSpinner.vue';
|
||||||
export { default as Modal } from './Modal.vue';
|
export { default as Modal } from './Modal.vue';
|
||||||
|
export { default as ProgressBar } from './ProgressBar.vue';
|
||||||
|
export { default as SelectInput } from './SelectInput.vue';
|
||||||
export { default as SliderCheckbox } from './SliderCheckbox.vue';
|
export { default as SliderCheckbox } from './SliderCheckbox.vue';
|
||||||
export { default as Tooltip } from './Tooltip.vue';
|
export { default as Tooltip } from './tooltip/Tooltip.vue';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { SortDirection } from './sort';
|
import {HeaderComponent, SortDirection} from './sort';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentSortKey: string | null;
|
currentSortKey: string | null;
|
||||||
@@ -7,6 +7,7 @@ interface Props {
|
|||||||
showColumn?: (k: string) => boolean;
|
showColumn?: (k: string) => boolean;
|
||||||
unsortable?: boolean;
|
unsortable?: boolean;
|
||||||
sortKey: string;
|
sortKey: string;
|
||||||
|
headerComponent?: HeaderComponent;
|
||||||
}
|
}
|
||||||
interface Emit {
|
interface Emit {
|
||||||
(e: 'sort', key: string, direction: SortDirection): void;
|
(e: 'sort', key: string, direction: SortDirection): void;
|
||||||
@@ -14,28 +15,30 @@ interface Emit {
|
|||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
withDefaults(defineProps<Props>(), {
|
||||||
showColumn: () => () => true,
|
showColumn: () => () => true,
|
||||||
unsortable: false
|
unsortable: false,
|
||||||
|
headerComponent: 'th',
|
||||||
});
|
});
|
||||||
const emit = defineEmits<Emit>();
|
const emit = defineEmits<Emit>();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<th v-if="showColumn(sortKey)">
|
<component v-if="showColumn(sortKey)" :is="headerComponent" class="sort-header">
|
||||||
<slot />
|
<slot />
|
||||||
<template v-if="!unsortable">
|
<template v-if="!unsortable">
|
||||||
<span class="asc" :class="{'opacity-20': (currentSortKey != sortKey || sortDirection != 'asc')}" @click="emit('sort', sortKey, 'asc')">▲</span>
|
<span class="asc" :class="{'opacity-20': (currentSortKey != sortKey || sortDirection != 'asc')}" @click="emit('sort', sortKey, 'asc')">▲</span>
|
||||||
<span class="desc" :class="{'opacity-20': (currentSortKey != sortKey || sortDirection != 'desc')}" @click="emit('sort', sortKey, 'desc')">▼</span>
|
<span class="desc" :class="{'opacity-20': (currentSortKey != sortKey || sortDirection != 'desc')}" @click="emit('sort', sortKey, 'desc')">▼</span>
|
||||||
</template>
|
</template>
|
||||||
</th>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
th {
|
@reference "@/style.css";
|
||||||
|
.sort-header {
|
||||||
@apply relative h-8 pe-3;
|
@apply relative h-8 pe-3;
|
||||||
}
|
}
|
||||||
span.asc, span.desc {
|
span.asc, span.desc {
|
||||||
@apply absolute end-1 cursor-pointer text-xs;
|
@apply absolute end-1 cursor-pointer text-xs transition-opacity;
|
||||||
}
|
}
|
||||||
span.asc {
|
span.asc {
|
||||||
@apply top-0.5;
|
@apply top-0.5;
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {useElementBounding, useVirtualList} from '@vueuse/core';
|
||||||
|
import {computed, ref} from 'vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
list?: any[];
|
||||||
|
itemHeight: number;
|
||||||
|
headerHeight?: number;
|
||||||
|
footerHeight?: number;
|
||||||
|
bottom?: string; // FIXME: use css variable
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
list: () => [],
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const { list: values, containerProps, wrapperProps } = useVirtualList(computed(() => props.list), {
|
||||||
|
itemHeight: () => props.itemHeight,
|
||||||
|
overscan: 3
|
||||||
|
})
|
||||||
|
|
||||||
|
const tableTop = ref<HTMLSpanElement | null>(null);
|
||||||
|
const { bottom: offset } = useElementBounding(tableTop);
|
||||||
|
const ypx = computed(() => {
|
||||||
|
let y = (offset.value ?? 0) + 'px';
|
||||||
|
|
||||||
|
if (props.bottom) {
|
||||||
|
y = `calc(${y} + ${props.bottom})`;
|
||||||
|
}
|
||||||
|
return y;
|
||||||
|
})
|
||||||
|
const computedHeaderHeight = computed(() => {
|
||||||
|
const h = props.headerHeight ?? props.itemHeight ?? 0;
|
||||||
|
|
||||||
|
return h + 'px';
|
||||||
|
})
|
||||||
|
const computedFooterHeight = computed(() => {
|
||||||
|
const h = props.footerHeight ?? 0;
|
||||||
|
|
||||||
|
return h + 'px';
|
||||||
|
})
|
||||||
|
const computedWrapperProps = computed(() => ({
|
||||||
|
...wrapperProps.value,
|
||||||
|
style: {
|
||||||
|
...wrapperProps.value.style,
|
||||||
|
height: `calc(${wrapperProps.value.style.height} + ${computedHeaderHeight.value} + ${computedFooterHeight.value} + 1px)`
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
const itemHeightStyle = computed(() => {
|
||||||
|
const h = props.itemHeight ?? 0;
|
||||||
|
|
||||||
|
return h + 'px';
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span ref="tableTop" class="h-0" />
|
||||||
|
<div v-if="list.length > 0" v-bind="containerProps" class="table-container">
|
||||||
|
<div v-bind="computedWrapperProps">
|
||||||
|
<table>
|
||||||
|
<slot :list="values" />
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<slot v-else name="empty" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
|
||||||
|
div.table-container {
|
||||||
|
@apply bg-slate-600;
|
||||||
|
max-height: calc(100vh - v-bind(ypx));
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
margin-top: v-bind(computedHeaderHeight);
|
||||||
|
margin-bottom: v-bind(computedFooterHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div.table-container:deep(>div) {
|
||||||
|
@apply bg-slate-800;
|
||||||
|
>table {
|
||||||
|
>thead {
|
||||||
|
@apply sticky z-10;
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
>tfoot {
|
||||||
|
@apply bg-slate-600 sticky z-10;
|
||||||
|
bottom: -1px;
|
||||||
|
}
|
||||||
|
>*>tr, >*>tr>td {
|
||||||
|
height: v-bind(itemHeightStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
export { default as SortableHeader } from './SortableHeader.vue';
|
|
||||||
export * from './sort';
|
export * from './sort';
|
||||||
|
|
||||||
|
export { default as SortableHeader } from './SortableHeader.vue';
|
||||||
|
export { default as VirtualScrollTable } from './VirtualScrollTable.vue';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useSort } from './sort'
|
||||||
|
|
||||||
|
describe('useSort', () => {
|
||||||
|
const array = ref([{ key1: 'b', key2: 'a' }, { key1: 'a', key2: 'b' }])
|
||||||
|
|
||||||
|
test('Returns expected properties with default options', () => {
|
||||||
|
const { sortedArray, headerProps, showColumn } = useSort(array)
|
||||||
|
|
||||||
|
expect(sortedArray).toBeDefined()
|
||||||
|
expect(headerProps).toBeDefined()
|
||||||
|
expect(showColumn).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Returns expected properties with custom options', () => {
|
||||||
|
const { sortedArray, headerProps, showColumn } = useSort(array, { defaultSortKey: 'key1', defaultSortDirection: 'asc' })
|
||||||
|
|
||||||
|
expect(sortedArray.value[0].key1).toBe('a')
|
||||||
|
expect(headerProps.value.currentSortKey).toBe('key1')
|
||||||
|
expect(headerProps.value.sortDirection).toBe('asc')
|
||||||
|
expect(showColumn('key1')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Sorts array in ascending order', () => {
|
||||||
|
const { sortedArray, headerProps } = useSort(array, { defaultSortKey: 'key1', defaultSortDirection: 'asc' })
|
||||||
|
|
||||||
|
headerProps.value.onSort('key1', 'asc')
|
||||||
|
expect(sortedArray.value[0].key1).toBe('a')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Sorts array in descending order', () => {
|
||||||
|
const { sortedArray, headerProps } = useSort(array, { defaultSortKey: 'key1', defaultSortDirection: 'desc' })
|
||||||
|
|
||||||
|
headerProps.value.onSort('key1', 'desc')
|
||||||
|
expect(sortedArray.value[0].key1).toBe('b')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Hides ignored columns', () => {
|
||||||
|
const { showColumn } = useSort(array, { ignoredColums: ['key1'] })
|
||||||
|
|
||||||
|
expect(showColumn('key1')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { MaybeRefOrGetter, computed, ref, toValue } from "vue";
|
import { Component, DefineComponent, MaybeRefOrGetter, computed, ref, toValue } from "vue";
|
||||||
|
|
||||||
|
export type HeaderComponent = Component | DefineComponent | string;
|
||||||
export type SortDirection = "asc" | "desc";
|
export type SortDirection = "asc" | "desc";
|
||||||
export type UseSortOptions = {
|
export type UseSortOptions = {
|
||||||
defaultSortKey?: string;
|
defaultSortKey?: string;
|
||||||
defaultSortDirection?: SortDirection;
|
defaultSortDirection?: SortDirection;
|
||||||
ignoredColums?: MaybeRefOrGetter<string[]>;
|
ignoredColums?: MaybeRefOrGetter<string[]>;
|
||||||
|
headerComponent?: HeaderComponent;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSort = <T>(array: MaybeRefOrGetter<T[]>, options?: UseSortOptions) => {
|
export const useSort = <T>(array: MaybeRefOrGetter<T[]>, options?: UseSortOptions) => {
|
||||||
@@ -14,14 +16,16 @@ export const useSort = <T>(array: MaybeRefOrGetter<T[]>, options?: UseSortOption
|
|||||||
sortKey.value = key;
|
sortKey.value = key;
|
||||||
sortDirection.value = direction;
|
sortDirection.value = direction;
|
||||||
};
|
};
|
||||||
const showColumn = (sortKey: string) => toValue(options?.ignoredColums)?.includes(sortKey) ?? true;
|
const showColumn = (sortKey: string) => !toValue(options?.ignoredColums)?.includes(sortKey);
|
||||||
const headerProps = computed(() => ({
|
const headerProps = computed(() => ({
|
||||||
onSort: sortBy, showColumn,
|
onSort: sortBy,
|
||||||
|
showColumn,
|
||||||
currentSortKey: sortKey.value,
|
currentSortKey: sortKey.value,
|
||||||
sortDirection: sortDirection.value
|
sortDirection: sortDirection.value,
|
||||||
|
headerComponent: options?.headerComponent,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const sortedArray = computed(() => toValue(array).sort((a, b) => {
|
const sortedArray = computed(() => toValue(array).toSorted((a, b) => {
|
||||||
if (sortKey.value === null || sortDirection.value === null) {
|
if (sortKey.value === null || sortDirection.value === null) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {vElementHover} from '@vueuse/components';
|
||||||
|
import {useElementBounding} from '@vueuse/core';
|
||||||
|
import {computed, ref} from 'vue';
|
||||||
|
import {useSharedWindowSize} from './tooltip';
|
||||||
|
|
||||||
|
const open = defineModel('open', { default: false });
|
||||||
|
|
||||||
|
const { width, height } = useSharedWindowSize();
|
||||||
|
const mainDiv = ref<HTMLDivElement | null>(null);
|
||||||
|
const { top, left } = useElementBounding(mainDiv);
|
||||||
|
|
||||||
|
const positions = computed(() => {
|
||||||
|
if (top.value < height.value / 2) {
|
||||||
|
if (left.value < width.value / 2) {
|
||||||
|
return ['top', 'left'];
|
||||||
|
}
|
||||||
|
return ['top', 'right'];
|
||||||
|
}
|
||||||
|
if (left.value < width.value / 2) {
|
||||||
|
return ['bottom', 'left'];
|
||||||
|
}
|
||||||
|
return ['bottom', 'right'];
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="mainDiv" class="flex flex-col items-center justify-center" :class="{
|
||||||
|
'open': open,
|
||||||
|
'tooltip-top': positions.includes('top'),
|
||||||
|
'tooltip-bottom': positions.includes('bottom'),
|
||||||
|
'tooltip-left': positions.includes('left'),
|
||||||
|
'tooltip-right': positions.includes('right')
|
||||||
|
}">
|
||||||
|
<div v-element-hover="(h: boolean) => open = h" class="m-auto header">
|
||||||
|
<slot name="header" />
|
||||||
|
</div>
|
||||||
|
<div v-if="open" class="m-auto">
|
||||||
|
<div class="z-10 relative">
|
||||||
|
<div class="absolute">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { createSharedComposable, useWindowSize } from "@vueuse/core";
|
||||||
|
|
||||||
|
export const useSharedWindowSize = createSharedComposable(useWindowSize);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {computed} from "vue";
|
||||||
|
import {Modal} from "@/components";
|
||||||
|
import {useConfirmStore} from "./useConfirm";
|
||||||
|
|
||||||
|
const confirmStore = useConfirmStore();
|
||||||
|
|
||||||
|
const modalOpen = computed({
|
||||||
|
get: () => confirmStore.open,
|
||||||
|
set: value => {
|
||||||
|
if (!value) {
|
||||||
|
confirmStore.cancel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal v-model:open="modalOpen">
|
||||||
|
<div class="bg-slate-800 rounded pb-4 w-96">
|
||||||
|
<span class="m-2">{{ confirmStore.options.title ?? "Confirm" }}</span>
|
||||||
|
<hr />
|
||||||
|
<div class="m-4">{{ confirmStore.options.message }}</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button class="me-2" @click="confirmStore.cancel">{{ confirmStore.options.cancelLabel ?? "Cancel" }}</button>
|
||||||
|
<button class="confirm me-4" :class="confirmStore.options.danger ? 'danger' : ''" @click="confirmStore.accept">{{ confirmStore.options.confirmLabel ?? "Confirm" }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
|
||||||
|
button.confirm {
|
||||||
|
@apply border-emerald-500 bg-emerald-500 hover:bg-emerald-600;
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
@apply border-amber-900 bg-amber-900 hover:bg-amber-800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as ConfirmModal } from './ConfirmModal.vue';
|
||||||
|
export { confirm, useConfirmStore } from './useConfirm';
|
||||||
|
export type { ConfirmOptions } from './useConfirm';
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {defineStore} from "pinia";
|
||||||
|
import {ref} from "vue";
|
||||||
|
|
||||||
|
export interface ConfirmOptions {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useConfirmStore = defineStore('confirm', () => {
|
||||||
|
const open = ref(false);
|
||||||
|
const options = ref<ConfirmOptions>({message: ""});
|
||||||
|
let resolver: ((value: boolean) => void) | undefined;
|
||||||
|
|
||||||
|
const settle = (value: boolean) => {
|
||||||
|
open.value = false;
|
||||||
|
resolver?.(value);
|
||||||
|
resolver = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirm = (opts: ConfirmOptions | string): Promise<boolean> => {
|
||||||
|
options.value = typeof opts === "string" ? {message: opts} : opts;
|
||||||
|
open.value = true;
|
||||||
|
return new Promise<boolean>(resolve => {
|
||||||
|
resolver = resolve;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const accept = () => settle(true);
|
||||||
|
const cancel = () => settle(false);
|
||||||
|
|
||||||
|
return {open, options, confirm, accept, cancel};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const confirm = (opts: ConfirmOptions | string): Promise<boolean> => useConfirmStore().confirm(opts);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { formatEveDate, formatIsk } from './formaters'
|
||||||
|
|
||||||
|
describe('formatIsk', () => {
|
||||||
|
test('Formats ISK correctly', () => {
|
||||||
|
expect(formatIsk(123456789)).toBe('123.456.789,00 ISK')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatEveDate', () => {
|
||||||
|
test('Formats EVE date correctly', () => {
|
||||||
|
const date = new Date(Date.UTC(2022, 0, 1, 0, 0))
|
||||||
|
expect(formatEveDate(date)).toBe('2022.01.01 00:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Returns empty string for undefined date', () => {
|
||||||
|
expect(formatEveDate()).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Returns empty string for null date', () => {
|
||||||
|
expect(formatEveDate(null)).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,3 +10,11 @@ export const percentFormater = new Intl.NumberFormat("en-US", {
|
|||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0
|
maximumFractionDigits: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const timeFormat = new Intl.NumberFormat("en-US", {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
minimumIntegerDigits: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
export const formatEveDate = (date?: Date | null) => !date ? '' : `${date.getUTCFullYear()}.${timeFormat.format(date.getUTCMonth() + 1)}.${timeFormat.format(date.getUTCDate())} ${timeFormat.format(date.getUTCHours())}:${timeFormat.format(date.getUTCMinutes())}`;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* OpenAPI definition
|
||||||
|
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
|
||||||
|
*
|
||||||
|
* The version of the OpenAPI document: v0
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||||
|
* https://openapi-generator.tech
|
||||||
|
* Do not edit the class manually.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
import type { Configuration } from './configuration';
|
||||||
|
// Some imports not used depending on template conditions
|
||||||
|
// @ts-ignore
|
||||||
|
import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';
|
||||||
|
import globalAxios from 'axios';
|
||||||
|
|
||||||
|
export const BASE_PATH = "http://localhost:8080".replace(/\/+$/, "");
|
||||||
|
|
||||||
|
export const COLLECTION_FORMATS = {
|
||||||
|
csv: ",",
|
||||||
|
ssv: " ",
|
||||||
|
tsv: "\t",
|
||||||
|
pipes: "|",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface RequestArgs {
|
||||||
|
url: string;
|
||||||
|
options: RawAxiosRequestConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BaseAPI {
|
||||||
|
protected configuration: Configuration | undefined;
|
||||||
|
|
||||||
|
constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) {
|
||||||
|
if (configuration) {
|
||||||
|
this.configuration = configuration;
|
||||||
|
this.basePath = configuration.basePath ?? basePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export class RequiredError extends Error {
|
||||||
|
constructor(public field: string, msg?: string) {
|
||||||
|
super(msg);
|
||||||
|
this.name = "RequiredError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerMap {
|
||||||
|
[key: string]: {
|
||||||
|
url: string,
|
||||||
|
description: string,
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const operationServerMap: ServerMap = {
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* OpenAPI definition
|
||||||
|
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
|
||||||
|
*
|
||||||
|
* The version of the OpenAPI document: v0
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||||
|
* https://openapi-generator.tech
|
||||||
|
* Do not edit the class manually.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Configuration } from "./configuration";
|
||||||
|
import type { RequestArgs } from "./base";
|
||||||
|
import type { AxiosInstance, AxiosResponse } from 'axios';
|
||||||
|
import { RequiredError } from "./base";
|
||||||
|
|
||||||
|
export const DUMMY_BASE_URL = 'https://example.com'
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) {
|
||||||
|
if (paramValue === null || paramValue === undefined) {
|
||||||
|
throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) {
|
||||||
|
if (configuration && configuration.apiKey) {
|
||||||
|
const localVarApiKeyValue = typeof configuration.apiKey === 'function'
|
||||||
|
? await configuration.apiKey(keyParamName)
|
||||||
|
: await configuration.apiKey;
|
||||||
|
object[keyParamName] = localVarApiKeyValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setBasicAuthToObject = function (object: any, configuration?: Configuration) {
|
||||||
|
if (configuration && (configuration.username || configuration.password)) {
|
||||||
|
object["auth"] = { username: configuration.username, password: configuration.password };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) {
|
||||||
|
if (configuration && configuration.accessToken) {
|
||||||
|
const accessToken = typeof configuration.accessToken === 'function'
|
||||||
|
? await configuration.accessToken()
|
||||||
|
: await configuration.accessToken;
|
||||||
|
object["Authorization"] = "Bearer " + accessToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) {
|
||||||
|
if (configuration && configuration.accessToken) {
|
||||||
|
const localVarAccessTokenValue = typeof configuration.accessToken === 'function'
|
||||||
|
? await configuration.accessToken(name, scopes)
|
||||||
|
: await configuration.accessToken;
|
||||||
|
object["Authorization"] = "Bearer " + localVarAccessTokenValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function setFlattenedQueryParams(urlSearchParams: URLSearchParams, parameter: any, key: string = ""): void {
|
||||||
|
if (parameter == null) return;
|
||||||
|
if (typeof parameter === "object") {
|
||||||
|
if (Array.isArray(parameter) || parameter instanceof Set) {
|
||||||
|
(parameter as any[]).forEach(item => setFlattenedQueryParams(urlSearchParams, item, key));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Object.keys(parameter).forEach(currentKey =>
|
||||||
|
setFlattenedQueryParams(urlSearchParams, parameter[currentKey], `${key}${key !== '' ? '.' : ''}${currentKey}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (urlSearchParams.has(key)) {
|
||||||
|
urlSearchParams.append(key, parameter);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
urlSearchParams.set(key, parameter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setSearchParams = function (url: URL, ...objects: any[]) {
|
||||||
|
const searchParams = new URLSearchParams(url.search);
|
||||||
|
setFlattenedQueryParams(searchParams, objects);
|
||||||
|
url.search = searchParams.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON serialization helper function which replaces instances of unserializable types with serializable ones.
|
||||||
|
* This function will run for every key-value pair encountered by JSON.stringify while traversing an object.
|
||||||
|
* Converting a set to a string will return an empty object, so an intermediate conversion to an array is required.
|
||||||
|
*/
|
||||||
|
// @ts-ignore
|
||||||
|
export const replaceWithSerializableTypeIfNeeded = function(key: string, value: any) {
|
||||||
|
if (value instanceof Set) {
|
||||||
|
return Array.from(value);
|
||||||
|
} else {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) {
|
||||||
|
const nonString = typeof value !== 'string';
|
||||||
|
const needsSerialization = nonString && configuration && configuration.isJsonMime
|
||||||
|
? configuration.isJsonMime(requestOptions.headers['Content-Type'])
|
||||||
|
: nonString;
|
||||||
|
return needsSerialization
|
||||||
|
? JSON.stringify(value !== undefined ? value : {}, replaceWithSerializableTypeIfNeeded)
|
||||||
|
: (value || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toPathString = function (url: URL) {
|
||||||
|
return url.pathname + url.search + url.hash
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) {
|
||||||
|
return <T = unknown, R = AxiosResponse<T>>(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => {
|
||||||
|
const axiosRequestArgs = {...axiosArgs.options, url: (axios.defaults.baseURL ? '' : configuration?.basePath ?? basePath) + axiosArgs.url};
|
||||||
|
return axios.request<T, R>(axiosRequestArgs);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/**
|
||||||
|
* OpenAPI definition
|
||||||
|
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
|
||||||
|
*
|
||||||
|
* The version of the OpenAPI document: v0
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||||
|
* https://openapi-generator.tech
|
||||||
|
* Do not edit the class manually.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface AWSv4Configuration {
|
||||||
|
options?: {
|
||||||
|
region?: string
|
||||||
|
service?: string
|
||||||
|
}
|
||||||
|
credentials?: {
|
||||||
|
accessKeyId?: string
|
||||||
|
secretAccessKey?: string,
|
||||||
|
sessionToken?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigurationParameters {
|
||||||
|
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
|
||||||
|
awsv4?: AWSv4Configuration;
|
||||||
|
basePath?: string;
|
||||||
|
serverIndex?: number;
|
||||||
|
baseOptions?: any;
|
||||||
|
formDataCtor?: new () => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Configuration {
|
||||||
|
/**
|
||||||
|
* parameter for apiKey security
|
||||||
|
* @param name security name
|
||||||
|
*/
|
||||||
|
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
|
||||||
|
/**
|
||||||
|
* parameter for basic security
|
||||||
|
*/
|
||||||
|
username?: string;
|
||||||
|
/**
|
||||||
|
* parameter for basic security
|
||||||
|
*/
|
||||||
|
password?: string;
|
||||||
|
/**
|
||||||
|
* parameter for oauth2 security
|
||||||
|
* @param name security name
|
||||||
|
* @param scopes oauth2 scope
|
||||||
|
*/
|
||||||
|
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
|
||||||
|
/**
|
||||||
|
* parameter for aws4 signature security
|
||||||
|
* @param {Object} AWS4Signature - AWS4 Signature security
|
||||||
|
* @param {string} options.region - aws region
|
||||||
|
* @param {string} options.service - name of the service.
|
||||||
|
* @param {string} credentials.accessKeyId - aws access key id
|
||||||
|
* @param {string} credentials.secretAccessKey - aws access key
|
||||||
|
* @param {string} credentials.sessionToken - aws session token
|
||||||
|
* @memberof Configuration
|
||||||
|
*/
|
||||||
|
awsv4?: AWSv4Configuration;
|
||||||
|
/**
|
||||||
|
* override base path
|
||||||
|
*/
|
||||||
|
basePath?: string;
|
||||||
|
/**
|
||||||
|
* override server index
|
||||||
|
*/
|
||||||
|
serverIndex?: number;
|
||||||
|
/**
|
||||||
|
* base options for axios calls
|
||||||
|
*/
|
||||||
|
baseOptions?: any;
|
||||||
|
/**
|
||||||
|
* The FormData constructor that will be used to create multipart form data
|
||||||
|
* requests. You can inject this here so that execution environments that
|
||||||
|
* do not support the FormData class can still run the generated client.
|
||||||
|
*
|
||||||
|
* @type {new () => FormData}
|
||||||
|
*/
|
||||||
|
formDataCtor?: new () => any;
|
||||||
|
|
||||||
|
constructor(param: ConfigurationParameters = {}) {
|
||||||
|
this.apiKey = param.apiKey;
|
||||||
|
this.username = param.username;
|
||||||
|
this.password = param.password;
|
||||||
|
this.accessToken = param.accessToken;
|
||||||
|
this.awsv4 = param.awsv4;
|
||||||
|
this.basePath = param.basePath;
|
||||||
|
this.serverIndex = param.serverIndex;
|
||||||
|
this.baseOptions = {
|
||||||
|
...param.baseOptions,
|
||||||
|
headers: {
|
||||||
|
...param.baseOptions?.headers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.formDataCtor = param.formDataCtor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given MIME is a JSON MIME.
|
||||||
|
* JSON MIME examples:
|
||||||
|
* application/json
|
||||||
|
* application/json; charset=UTF8
|
||||||
|
* APPLICATION/JSON
|
||||||
|
* application/vnd.company+json
|
||||||
|
* @param mime - MIME (Multipurpose Internet Mail Extensions)
|
||||||
|
* @return True if the given MIME is JSON, false otherwise.
|
||||||
|
*/
|
||||||
|
public isJsonMime(mime: string): boolean {
|
||||||
|
const jsonMime: RegExp = /^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$/i;
|
||||||
|
return mime !== null && jsonMime.test(mime);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* OpenAPI definition
|
||||||
|
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
|
||||||
|
*
|
||||||
|
* The version of the OpenAPI document: v0
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||||
|
* https://openapi-generator.tech
|
||||||
|
* Do not edit the class manually.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
export * from "./api";
|
||||||
|
export * from "./configuration";
|
||||||
|
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import {computed, ref} from "vue";
|
||||||
|
import {isCombined, Ledger, LedgerType, LedgerTypes, useLedgersStore} from "./ledger";
|
||||||
|
import {Modal} from "@/components";
|
||||||
|
import LedgerLabel from "./LedgerLabel.vue";
|
||||||
|
import {PlusIcon, TrashIcon} from '@heroicons/vue/24/outline';
|
||||||
|
import LedgerSelect from "@/ledger/LedgerSelect.vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ledgerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const ledgersStore = useLedgersStore();
|
||||||
|
|
||||||
|
const modalOpen = ref<boolean>(false);
|
||||||
|
|
||||||
|
const type = ref<LedgerType>(LedgerTypes.Main);
|
||||||
|
const name = ref("");
|
||||||
|
const members = ref<Ledger[]>([]);
|
||||||
|
const selectedLedger = ref<Ledger>();
|
||||||
|
const availableLedgers = computed(() => ledgersStore.ledgers
|
||||||
|
.filter(l => l.ledgerId !== props.ledgerId)
|
||||||
|
.filter(l => !members.value.includes(l)));
|
||||||
|
|
||||||
|
|
||||||
|
const addMember = () => {
|
||||||
|
if (selectedLedger.value && !members.value.includes(selectedLedger.value)) {
|
||||||
|
members.value = [...members.value, selectedLedger.value];
|
||||||
|
selectedLedger.value = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
const ledger = isCreating.value ? undefined : ledgersStore.findById(props.ledgerId);
|
||||||
|
|
||||||
|
if (ledger) {
|
||||||
|
type.value = ledger.type;
|
||||||
|
name.value = ledger.name;
|
||||||
|
members.value = isCombined(ledger) ? ledgersStore.findAllById(ledger.memberLedgerIds) : [];
|
||||||
|
} else {
|
||||||
|
type.value = LedgerTypes.Main;
|
||||||
|
name.value = "";
|
||||||
|
members.value = [];
|
||||||
|
}
|
||||||
|
modalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSave = computed(() => name.value.trim().length > 0);
|
||||||
|
const isCreating = computed(() => props.ledgerId === undefined || props.ledgerId.length === 0);
|
||||||
|
const title = computed(() => {
|
||||||
|
if (isCreating.value) {
|
||||||
|
return `Creating ${type.value === LedgerTypes.Main ? 'Main' : 'Combined'} Ledger`
|
||||||
|
}
|
||||||
|
return `Updating ${name.value}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const create = () => {
|
||||||
|
if (type.value === LedgerTypes.Main) {
|
||||||
|
ledgersStore.createMain({name: name.value})
|
||||||
|
} else {
|
||||||
|
ledgersStore.createCombined({name: name.value, memberLedgerIds: members.value.map(l => l.ledgerId)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
if (type.value === LedgerTypes.Main) {
|
||||||
|
ledgersStore.updateMain(props.ledgerId, {name: name.value})
|
||||||
|
} else {
|
||||||
|
ledgersStore.updateCombined(props.ledgerId, {name: name.value, memberLedgerIds: members.value.map(l => l.ledgerId)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (!canSave.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCreating.value) {
|
||||||
|
create();
|
||||||
|
} else {
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
modalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal v-model:open="modalOpen">
|
||||||
|
<div class="bg-slate-800 rounded pb-4 w-96">
|
||||||
|
<span class="m-2">{{ title }}</span>
|
||||||
|
<hr />
|
||||||
|
<div class="mt-4">
|
||||||
|
<div v-if="!ledgerId" class="flex justify-center">
|
||||||
|
<div class="flex bg-slate-600 rounded-s-md p-1">
|
||||||
|
<button class="switch" :class="{active: type === LedgerTypes.Main}" @click="type = LedgerTypes.Main">Main</button>
|
||||||
|
</div>
|
||||||
|
<div class="switch flex bg-slate-600 rounded-e-md p-1">
|
||||||
|
<button class="switch" :class="{active: type === LedgerTypes.Combined}" @click="type = LedgerTypes.Combined">Combined</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-4">
|
||||||
|
Name:
|
||||||
|
<div class="flex">
|
||||||
|
<input type="text" class="flex grow" v-model="name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="type === LedgerTypes.Combined" class="ms-4 mb-4">
|
||||||
|
Member Ledgers:
|
||||||
|
<div v-for="ledger in members" :key="ledger.ledgerId" class="flex">
|
||||||
|
<LedgerLabel class="flex grow mb-2" :ledger="ledger" />
|
||||||
|
<div class="flex justify-end me-4 ms-2">
|
||||||
|
<button class="btn-icon" @click="members = members.filter(m => m !== ledger)"><TrashIcon /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="availableLedgers.length" class="flex">
|
||||||
|
<LedgerSelect v-model="selectedLedger" class="grow" :ledgers="availableLedgers" />
|
||||||
|
<div class="flex justify-end me-4 ms-2">
|
||||||
|
<button class="btn-icon" @click="addMember"><PlusIcon /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button class="me-4" @click="save" :disabled="!canSave">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
|
||||||
|
button.switch {
|
||||||
|
@apply flex items-center px-4 rounded-md bg-slate-600;
|
||||||
|
&.active {
|
||||||
|
@apply bg-emerald-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import {isCombined, Ledger, systemLedger} from "@/ledger/ledger.ts";
|
||||||
|
import {FolderOpenIcon} from '@heroicons/vue/24/outline';
|
||||||
|
import {RouterLink} from "vue-router";
|
||||||
|
import {routeNames} from "@/routes";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ledger: Ledger;
|
||||||
|
link?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex">
|
||||||
|
<FolderOpenIcon v-if="isCombined(ledger)" class="w-4 me-1" />
|
||||||
|
<div v-else class="w-4 me-1"/>
|
||||||
|
<RouterLink v-if="link" :to="{name: routeNames.viewLedger, params: {ledgerId: ledger.ledgerId}}">{{ ledger.name }}</RouterLink>
|
||||||
|
<span v-else :class="{'system-ledger': ledger === systemLedger}">{{ ledger.name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
|
||||||
|
.system-ledger {
|
||||||
|
@apply text-emerald-400;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {Ledger, systemLedger, useLedgersStore} from "@/ledger/ledger.ts";
|
||||||
|
import {computed} from "vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ledgers?: Ledger[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const ledger = defineModel<Ledger>();
|
||||||
|
const ledgersStore = useLedgersStore();
|
||||||
|
|
||||||
|
const ledgersToUse = computed(() => props.ledgers || ledgersStore.ledgers);
|
||||||
|
const ledgerId = computed({
|
||||||
|
get: () => ledger.value?.ledgerId,
|
||||||
|
set: value => ledger.value = ledgersToUse.value.find(l => l.ledgerId === value)
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<select v-model="ledgerId" :class="{'system-ledger': ledger === systemLedger}">
|
||||||
|
<option v-for="l in ledgersToUse" :key="l.ledgerId" :value="l.ledgerId" :class="{'system-ledger': l === systemLedger}">{{ l.name }}</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
|
||||||
|
.system-ledger {
|
||||||
|
@apply text-emerald-400;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './ledger';
|
||||||
|
|
||||||
|
export {default as LedgerLabel} from './LedgerLabel.vue';
|
||||||
|
export {default as LedgerSelect} from './LedgerSelect.vue';
|
||||||
|
export {default as EditLedgerModal} from './EditLedgerModal.vue';
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
BalanceResponse,
|
||||||
|
CombinedLedgerResponse,
|
||||||
|
CreateCombinedLedgerRequest,
|
||||||
|
CreateMainLedgerRequest,
|
||||||
|
LedgerResponse,
|
||||||
|
MainLedgerResponse,
|
||||||
|
TransactionResponse,
|
||||||
|
UpdateCombinedLedgerRequest,
|
||||||
|
UpdateMainLedgerRequest
|
||||||
|
} from "@/generated/mammon";
|
||||||
|
import {defineStore} from "pinia";
|
||||||
|
import {computed, ref, triggerRef} from "vue";
|
||||||
|
import {ledgerApi, transactionApi} from "@/mammon";
|
||||||
|
import {useRouteParams} from "@vueuse/router";
|
||||||
|
|
||||||
|
export const LedgerTypes = {
|
||||||
|
Main: 'MAIN',
|
||||||
|
Combined: 'COMBINED',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LedgerType = LedgerResponse['type'];
|
||||||
|
export type MainLedger = MainLedgerResponse
|
||||||
|
export type CombinedLedger = CombinedLedgerResponse
|
||||||
|
export type Ledger = MainLedger | CombinedLedger;
|
||||||
|
|
||||||
|
export const systemLedgerRef = 'system';
|
||||||
|
export const systemLedger = {
|
||||||
|
type: LedgerTypes.Main,
|
||||||
|
ledgerId: "00000000-0000-0000-0000-000000000001",
|
||||||
|
name: "Eve Economy",
|
||||||
|
balance: 0,
|
||||||
|
_system: true
|
||||||
|
} as MainLedger;
|
||||||
|
|
||||||
|
export const isMain = (ledger: Ledger): ledger is MainLedger => {
|
||||||
|
return ledger.type === LedgerTypes.Main;
|
||||||
|
}
|
||||||
|
export const isCombined = (ledger: Ledger): ledger is CombinedLedger => {
|
||||||
|
return ledger.type === LedgerTypes.Combined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLedgersStore = defineStore('ledgers', () => {
|
||||||
|
const ledgers = ref<Ledger[]>([]);
|
||||||
|
|
||||||
|
const addLedger = (ledger: Ledger) => {
|
||||||
|
ledgers.value.push(ledger);
|
||||||
|
triggerRef(ledgers);
|
||||||
|
return ledger;
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceLedger = (ledger: Ledger) => {
|
||||||
|
const index = ledgers.value.findIndex(l => l.ledgerId === ledger.ledgerId);
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
|
ledgers.value[index] = ledger;
|
||||||
|
}
|
||||||
|
triggerRef(ledgers);
|
||||||
|
return ledger;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findById = (ledgerId: string): Ledger | undefined => ledgers.value.find(l => l.ledgerId === ledgerId);
|
||||||
|
const findAllById = (ledgerIds: string[]): Ledger[] => ledgerIds.map(findById).filter((x): x is Ledger => x !== undefined)
|
||||||
|
|
||||||
|
const createMain = (ledger: CreateMainLedgerRequest) => ledgerApi.createMainLedger(ledger).then(response => addLedger(response.data as Ledger));
|
||||||
|
const createCombined = (ledger: CreateCombinedLedgerRequest) => ledgerApi.createCombinedLedger(ledger).then(response => addLedger(response.data as Ledger));
|
||||||
|
const updateMain = (ledgerId: string, ledger: UpdateMainLedgerRequest) => ledgerApi.updateMainLedger(ledgerId, ledger).then(response => replaceLedger(response.data as Ledger));
|
||||||
|
const updateCombined = (ledgerId: string, ledger: UpdateCombinedLedgerRequest) => ledgerApi.updateCombinedLedger(ledgerId, ledger).then(response => replaceLedger(response.data as Ledger));
|
||||||
|
|
||||||
|
const refresh = () => ledgerApi.findAllLedgers().then(response => ledgers.value = response.data as Ledger[]);
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
return {ledgers, findById, findAllById, createMain, createCombined, updateMain, updateCombined, refresh};
|
||||||
|
})
|
||||||
|
|
||||||
|
const getLedgerId = (ledger: Ledger | string): string => typeof ledger == 'string' ? ledger : ledger.ledgerId;
|
||||||
|
|
||||||
|
export const findAllTransactionInLeger = (ledger: Ledger | string): Promise<TransactionResponse[]> => transactionApi.finAllTransactionsInLedger(getLedgerId(ledger)).then(response => response.data)
|
||||||
|
export const getLedgerBalance = (ledger: Ledger | string): Promise<BalanceResponse> => ledgerApi.findBalanceByLedgerId(getLedgerId(ledger)).then(response => response.data)
|
||||||
|
|
||||||
|
export const useLedgerParam = () => {
|
||||||
|
const ledgersStore = useLedgersStore();
|
||||||
|
const ledgerId = useRouteParams<string, string>('ledgerId', '', { transform: v => typeof v === 'string' ? v : v[0]});
|
||||||
|
const ledger = computed(() => ledgersStore.findById(ledgerId.value))
|
||||||
|
|
||||||
|
return {ledgerId, ledger};
|
||||||
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import log from "loglevel";
|
|||||||
import { apply, reg } from "loglevel-plugin-prefix";
|
import { apply, reg } from "loglevel-plugin-prefix";
|
||||||
|
|
||||||
export function initLogger() {
|
export function initLogger() {
|
||||||
log.setLevel(process.env.NODE_ENV === 'production' ? 'info' : 'trace');
|
log.setLevel(import.meta.env.VITE_LOG_LEVEL);
|
||||||
reg(log);
|
reg(log);
|
||||||
apply(log, {template: '[%t] %l:'});
|
apply(log, {template: '[%t] %l:'});
|
||||||
}
|
}
|
||||||
|
|||||||
-10
@@ -1,4 +1,3 @@
|
|||||||
import { providePocketBase } from '@/pocketbase';
|
|
||||||
import { createPinia } from 'pinia';
|
import { createPinia } from 'pinia';
|
||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
@@ -10,21 +9,12 @@ import './style.css';
|
|||||||
initLogger();
|
initLogger();
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
const pb = providePocketBase(app);
|
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes,
|
routes,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.beforeEach(async to => {
|
|
||||||
if (!pb.authStore.isValid && to.name !== 'login') {
|
|
||||||
return { name: 'login' };
|
|
||||||
} else if (pb.authStore.isValid && to.name === 'login') {
|
|
||||||
return { name: 'home' };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use(pinia);
|
app.use(pinia);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './mammonService'
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {logResource} from "@/service";
|
||||||
|
import axios from "axios";
|
||||||
|
import {
|
||||||
|
AcquisitionApi,
|
||||||
|
ActivityApi,
|
||||||
|
CharacterApi,
|
||||||
|
CharacterRuleBookApi,
|
||||||
|
LedgerApi,
|
||||||
|
MarketApi,
|
||||||
|
ProcessingApi,
|
||||||
|
RuleBookApi,
|
||||||
|
TransactionApi
|
||||||
|
} from "@/generated/mammon";
|
||||||
|
|
||||||
|
export const mammonUrl = import.meta.env.VITE_MAMMON_URL;
|
||||||
|
export const mammonAddCharacterUrl = mammonUrl + "oauth2/authorization/esi"
|
||||||
|
|
||||||
|
const mammonAxiosInstance = axios.create({
|
||||||
|
baseURL: mammonUrl,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
logResource(mammonAxiosInstance)
|
||||||
|
|
||||||
|
export const ledgerApi = new LedgerApi(undefined, mammonUrl, mammonAxiosInstance);
|
||||||
|
export const transactionApi = new TransactionApi(undefined, mammonUrl, mammonAxiosInstance);
|
||||||
|
export const characterApi = new CharacterApi(undefined, mammonUrl, mammonAxiosInstance);
|
||||||
|
export const ruleBookApi = new RuleBookApi(undefined, mammonUrl, mammonAxiosInstance);
|
||||||
|
export const characterRuleBookApi = new CharacterRuleBookApi(undefined, mammonUrl, mammonAxiosInstance);
|
||||||
|
export const activityApi = new ActivityApi(undefined, mammonUrl, mammonAxiosInstance);
|
||||||
|
export const processingApi = new ProcessingApi(undefined, mammonUrl, mammonAxiosInstance);
|
||||||
|
export const acquisitionApi = new AcquisitionApi(undefined, mammonUrl, mammonAxiosInstance);
|
||||||
|
export const marketApi = new MarketApi(undefined, mammonUrl, mammonAxiosInstance);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {formatIsk} from "@/formaters";
|
||||||
|
import {computed} from "vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
amount: number;
|
||||||
|
colored?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
colored: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const computedClass = computed(() => {
|
||||||
|
if (!props.colored) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.amount >= 0 ? 'text-emerald-400' : 'text-amber-700';
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :class="computedClass">{{ formatIsk(amount) }}</span>
|
||||||
|
</template>
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { esiAxiosInstance } from "@/service";
|
|
||||||
|
|
||||||
|
|
||||||
export type MarketOrderHistory = {
|
|
||||||
average: number;
|
|
||||||
date: string;
|
|
||||||
highest: number;
|
|
||||||
lowest: number;
|
|
||||||
order_count: number;
|
|
||||||
volume: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getHistory = async (regionId: number, tyeId: number): Promise<MarketOrderHistory[]> => (await esiAxiosInstance.get(`/markets/${regionId}/history/`, { params: { type_id: tyeId } })).data;
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { RegionalMarketCache } from './RegionalMarketCache'
|
||||||
|
|
||||||
|
describe('RegionalMarketCache', () => {
|
||||||
|
test('should cache and retrieve values', async () => {
|
||||||
|
const cache = new RegionalMarketCache<string>(1000)
|
||||||
|
|
||||||
|
cache.set(1, 1, 'test')
|
||||||
|
expect(cache.get(1, 1)).toBe('test')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should remove values', async () => {
|
||||||
|
const cache = new RegionalMarketCache<string>(1000)
|
||||||
|
|
||||||
|
cache.set(1, 1, 'test')
|
||||||
|
cache.remove(1, 1)
|
||||||
|
expect(cache.get(1, 1)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should compute values if absent', async () => {
|
||||||
|
const cache = new RegionalMarketCache<string>(1000)
|
||||||
|
const value = await cache.computeIfAbsent(1, 1, () => Promise.resolve('test'))
|
||||||
|
|
||||||
|
expect(value).toBe('test')
|
||||||
|
expect(cache.get(1, 1)).toBe('test')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should expire values', async () => {
|
||||||
|
const cache = new RegionalMarketCache<string>(1)
|
||||||
|
|
||||||
|
cache.set(1, 1, 'test')
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10))
|
||||||
|
expect(cache.get(1, 1)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
class CacheEntry<T> {
|
||||||
|
public value: T;
|
||||||
|
public expiration: Date;
|
||||||
|
|
||||||
|
constructor(value: T, expiration: Date) {
|
||||||
|
this.value = value;
|
||||||
|
this.expiration = expiration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExpirationSupplier<T> = (v: T) => Date;
|
||||||
|
|
||||||
|
export class RegionalMarketCache<T> {
|
||||||
|
private cache: Record<number, Record<number, CacheEntry<T>>>;
|
||||||
|
private expirationSupplier: (v: T) => Date;
|
||||||
|
|
||||||
|
constructor(expiration: ExpirationSupplier<T> | number) {
|
||||||
|
this.cache = {};
|
||||||
|
this.expirationSupplier = expiration instanceof Function ? expiration : () => new Date(Date.now() + expiration);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(regionId: number, typeId: number): T | undefined {
|
||||||
|
const entry = this.cache[regionId]?.[typeId];
|
||||||
|
|
||||||
|
if (entry && entry.expiration > new Date()) {
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
this.remove(regionId, typeId);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public set(regionId: number, typeId: number, value: T): void {
|
||||||
|
this.cache[regionId] = this.cache[regionId] ?? {};
|
||||||
|
this.cache[regionId][typeId] = new CacheEntry(value, this.expirationSupplier(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public remove(regionId: number, typeId: number): void {
|
||||||
|
delete this.cache[regionId]?.[typeId];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async computeIfAbsent(regionId: number, typeId: number, supplier: () => (Promise<T> | T)): Promise<T> {
|
||||||
|
let value = this.get(regionId, typeId);
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
value = await supplier();
|
||||||
|
this.set(regionId, typeId, value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { MarketType } from "..";
|
||||||
|
import { RawAcquiredType } from "./acquisition";
|
||||||
|
|
||||||
|
export type AcquiredType = Omit<RawAcquiredType, 'type'> & {
|
||||||
|
type: MarketType,
|
||||||
|
buy: number,
|
||||||
|
sell: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const acquiredTypesToSorted = <T extends {date: Date} = AcquiredType>(array: T[], reverse?: boolean) => array.toSorted((a, b) => reverse ? b.date.getTime() - a.date.getTime() : a.date.getTime() - b.date.getTime())
|
||||||
+20
-9
@@ -1,8 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {LoadingSpinner, Tooltip} from '@/components';
|
import {LoadingSpinner, Tooltip} from '@/components';
|
||||||
import {formatIsk} from '@/formaters';
|
import {formatIsk} from '@/formaters';
|
||||||
import { getHistory, jitaId } from '@/market';
|
import {getHistory, getHistoryQuartils} from '@/market';
|
||||||
import { getHistoryQuartils } from '@/market/scan';
|
|
||||||
import {ArrowTrendingDownIcon, ArrowTrendingUpIcon} from '@heroicons/vue/24/outline';
|
import {ArrowTrendingDownIcon, ArrowTrendingUpIcon} from '@heroicons/vue/24/outline';
|
||||||
import {computedAsync} from '@vueuse/core';
|
import {computedAsync} from '@vueuse/core';
|
||||||
import {ref, watchEffect} from 'vue';
|
import {ref, watchEffect} from 'vue';
|
||||||
@@ -23,7 +22,7 @@ const q1 = ref(0);
|
|||||||
const median = ref(0);
|
const median = ref(0);
|
||||||
const q3 = ref(0);
|
const q3 = ref(0);
|
||||||
const lineColor = ref('');
|
const lineColor = ref('');
|
||||||
const history = computedAsync(() => getHistory(jitaId, props.id), []);
|
const history = computedAsync(() => getHistory(props.id), []);
|
||||||
|
|
||||||
watchEffect(async () => {
|
watchEffect(async () => {
|
||||||
if (!open.value || !props.id) {
|
if (!open.value || !props.id) {
|
||||||
@@ -53,7 +52,7 @@ watchEffect(async () => {
|
|||||||
<ArrowTrendingDownIcon v-else />
|
<ArrowTrendingDownIcon v-else />
|
||||||
</template>
|
</template>
|
||||||
<template #default>
|
<template #default>
|
||||||
<div class="bg-slate-500 -left-1/2 relative" v-if="history.length > 0">
|
<div class="bg-slate-500 -left-1/2 relative tooltip-content" v-if="history.length > 0">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -64,9 +63,9 @@ watchEffect(async () => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr :class="lineColor">
|
<tr :class="lineColor">
|
||||||
<td class="text-right">{{ formatIsk(q1) }}</td>
|
<td class="text-right text-nowrap">{{ formatIsk(q1) }}</td>
|
||||||
<td class="text-right">{{ formatIsk(median) }}</td>
|
<td class="text-right text-nowrap">{{ formatIsk(median) }}</td>
|
||||||
<td class="text-right">{{ formatIsk(q3) }}</td>
|
<td class="text-right text-nowrap">{{ formatIsk(q3) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -75,14 +74,26 @@ watchEffect(async () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
|
||||||
.tooltip {
|
.tooltip {
|
||||||
@apply ms-auto;
|
@apply ms-auto;
|
||||||
>:deep(div.header) {
|
>:deep(div.header) {
|
||||||
@apply btn-icon px-2;
|
@apply btn-icon px-2;
|
||||||
}
|
}
|
||||||
&.open>:deep(div.header) {
|
&.open {
|
||||||
|
&.tooltip-top>:deep(div.header) {
|
||||||
@apply rounded-t-md bg-slate-600;
|
@apply rounded-t-md bg-slate-600;
|
||||||
}
|
}
|
||||||
|
&.tooltip-bottom {
|
||||||
|
.tooltip-content {
|
||||||
|
bottom: 79px;
|
||||||
|
}
|
||||||
|
>:deep(div.header) {
|
||||||
|
@apply rounded-b-md bg-slate-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
|
||||||
|
import {MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore} from "@/market";
|
||||||
|
import {MinusIcon, PlusIcon} from '@heroicons/vue/24/outline';
|
||||||
|
import {useStorage} from '@vueuse/core';
|
||||||
|
import {computed, ref} from 'vue';
|
||||||
|
import {AcquiredType} from './AcquiredType';
|
||||||
|
import AcquisitionQuantilsTooltip from './AcquisitionQuantilsTooltip.vue';
|
||||||
|
import {formatEveDate, formatIsk, percentFormater} from "@/formaters.ts";
|
||||||
|
|
||||||
|
type Result = {
|
||||||
|
id: string;
|
||||||
|
type: MarketType;
|
||||||
|
name: string;
|
||||||
|
buy: number;
|
||||||
|
sell: number;
|
||||||
|
price: number;
|
||||||
|
remaining: number;
|
||||||
|
quantity: number;
|
||||||
|
precentProfit: number;
|
||||||
|
iskProfit: number;
|
||||||
|
date: Date;
|
||||||
|
acquisitions: AcquiredType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items?: AcquiredType[];
|
||||||
|
infoOnly?: boolean;
|
||||||
|
showAll?: boolean;
|
||||||
|
ignoredColums?: string[] | string;
|
||||||
|
defaultSortKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'buy', type: AcquiredType[], price: number, buy: number, sell: number): void;
|
||||||
|
(e: 'sell', type: AcquiredType[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
items: () => [],
|
||||||
|
infoOnly: false,
|
||||||
|
showAll: false,
|
||||||
|
ignoredColums: () => [],
|
||||||
|
defaultSortKey: 'precentProfit',
|
||||||
|
});
|
||||||
|
defineEmits<Emits>();
|
||||||
|
|
||||||
|
const columnsToIgnore = computed(() => {
|
||||||
|
const ic = typeof props.ignoredColums === 'string' ? [props.ignoredColums] : props.ignoredColums;
|
||||||
|
|
||||||
|
if (props.infoOnly && !ic.includes('buttons')) {
|
||||||
|
return [...ic, 'buttons'];
|
||||||
|
}
|
||||||
|
return ic;
|
||||||
|
});
|
||||||
|
|
||||||
|
const marketTaxStore = useMarketTaxStore();
|
||||||
|
|
||||||
|
const threshold = useStorage('market-acquisition-threshold', 10);
|
||||||
|
const filter = ref("");
|
||||||
|
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => {
|
||||||
|
const filteredItems = props.items.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase()));
|
||||||
|
|
||||||
|
if (props.showAll) {
|
||||||
|
return filteredItems.map(r => {
|
||||||
|
const precentProfit = marketTaxStore.calculateProfit(r.price, r.sell);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
type: r.type,
|
||||||
|
name: r.type.name,
|
||||||
|
buy: r.buy,
|
||||||
|
sell: r.sell,
|
||||||
|
price: r.price,
|
||||||
|
remaining: r.remaining,
|
||||||
|
quantity: r.quantity,
|
||||||
|
precentProfit,
|
||||||
|
iskProfit: r.price * precentProfit * r.remaining,
|
||||||
|
date: r.date,
|
||||||
|
acquisitions: [r]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const list: Result[] = [];
|
||||||
|
const groups = Map.groupBy(filteredItems, r => r.type.id);
|
||||||
|
|
||||||
|
groups.forEach((group, typeID) => {
|
||||||
|
const first = group[0];
|
||||||
|
|
||||||
|
if (!first) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = group.reduce((acc, r) => acc + r.quantity, 0);
|
||||||
|
const totalRemaining = group.reduce((acc, r) => acc + r.remaining, 0);
|
||||||
|
const price = group.reduce((acc, r) => acc + r.price * r.remaining, 0) / totalRemaining;
|
||||||
|
const precentProfit = marketTaxStore.calculateProfit(price, first.sell);
|
||||||
|
|
||||||
|
list.push({
|
||||||
|
id: typeID.toString(),
|
||||||
|
type: first.type,
|
||||||
|
name: first.type.name,
|
||||||
|
buy: first.buy,
|
||||||
|
sell: first.sell,
|
||||||
|
price: price,
|
||||||
|
remaining: totalRemaining,
|
||||||
|
quantity: total,
|
||||||
|
precentProfit,
|
||||||
|
iskProfit: price * precentProfit * totalRemaining,
|
||||||
|
date: first.date,
|
||||||
|
acquisitions: group
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}), {
|
||||||
|
defaultSortKey: props.defaultSortKey,
|
||||||
|
defaultSortDirection: 'desc',
|
||||||
|
ignoredColums: columnsToIgnore
|
||||||
|
})
|
||||||
|
const getLineColor = (result: Result) => {
|
||||||
|
if (result.precentProfit >= (threshold.value / 100)) {
|
||||||
|
return 'line-green';
|
||||||
|
} else if (result.precentProfit < 0) {
|
||||||
|
return 'line-red';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const total = computed(() => {
|
||||||
|
if (sortedArray.value.length <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = sortedArray.value[0];
|
||||||
|
|
||||||
|
if (!first) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameItem = sortedArray.value.every(r => r.type.id === first.type.id);
|
||||||
|
const quantity = sameItem ? sortedArray.value.reduce((acc, r) => acc + r.quantity, 0) : 0;
|
||||||
|
const totalRemaining = sameItem ? sortedArray.value.reduce((acc, r) => acc + r.remaining, 0) : 0;
|
||||||
|
const price = sortedArray.value.reduce((acc, r) => acc + r.price * r.remaining, 0) / totalRemaining;
|
||||||
|
const precentProfit = marketTaxStore.calculateProfit(price, first.sell);
|
||||||
|
const iskProfit = sortedArray.value.reduce((acc, r) => acc + r.iskProfit, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sameItem,
|
||||||
|
price,
|
||||||
|
remaining: totalRemaining,
|
||||||
|
quantity,
|
||||||
|
precentProfit,
|
||||||
|
iskProfit
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex" v-if="!infoOnly">
|
||||||
|
<div class="flex justify-self-end mb-2 mt-4 ms-auto">
|
||||||
|
<TaxInput />
|
||||||
|
<div class="end">
|
||||||
|
<span>Profit Threshold: </span>
|
||||||
|
<input type="number" min="0" max="1000" step="1" v-model="threshold" />
|
||||||
|
</div>
|
||||||
|
<div class="end">
|
||||||
|
<span>Filter: </span>
|
||||||
|
<input type="search" class="w-96" v-model="filter" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<VirtualScrollTable :list="sortedArray" :itemHeight="33" :footerHeight="!!total ? 33 : 0" bottom="1rem">
|
||||||
|
<template #default="{ list }">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="buy">Buy</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="sell">Sell</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="date">Bought at</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="price">Bought Price</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="remaining">Remaining Amount</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="precentProfit">Profit (%)</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="iskProfit">Profit (ISK)</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="buttons" unsortable />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in list" :key="r.index" :class="getLineColor(r.data)">
|
||||||
|
<td v-if="showColumn('name')">
|
||||||
|
<div class="flex">
|
||||||
|
<MarketTypeLabel :id="r.data.type.id" :name="r.data.name" />
|
||||||
|
<AcquisitionQuantilsTooltip :id="r.data.type.id" :buy="r.data.buy" :sell="r.data.sell" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td v-if="showColumn('buy')" class="text-right">{{ formatIsk(r.data.buy) }}</td>
|
||||||
|
<td v-if="showColumn('sell')" class="text-right">{{ formatIsk(r.data.sell) }}</td>
|
||||||
|
<td v-if="showColumn('date')" class="text-right">{{ formatEveDate(r.data.date) }}</td>
|
||||||
|
<td v-if="showColumn('price')" class="text-right">{{ formatIsk(r.data.price) }}</td>
|
||||||
|
<td v-if="showColumn('remaining')" class="text-right">{{ r.data.remaining }}/{{ r.data.quantity }}</td>
|
||||||
|
<td v-if="showColumn('precentProfit')" class="text-right">{{ percentFormater.format(r.data.precentProfit) }}</td>
|
||||||
|
<td v-if="showColumn('iskProfit')" class="text-right">{{ formatIsk(r.data.iskProfit) }}</td>
|
||||||
|
<td v-if="showColumn('buttons')" class="text-right">
|
||||||
|
<button class="btn-icon me-1" @click="$emit('buy', r.data.acquisitions, r.data.price, r.data.buy, r.data.sell)"><PlusIcon /></button>
|
||||||
|
<button class="btn-icon me-1" @click="$emit('sell', r.data.acquisitions)"><MinusIcon /></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot v-if="!!total">
|
||||||
|
<tr>
|
||||||
|
<td v-if="showColumn('name')">Total</td>
|
||||||
|
<td v-if="showColumn('buy')">
|
||||||
|
<template v-if="!showColumn('name')">Total</template>
|
||||||
|
</td>
|
||||||
|
<td v-if="showColumn('sell')">
|
||||||
|
<template v-if="!showColumn('name') && !showColumn('buy')">Total</template>
|
||||||
|
</td>
|
||||||
|
<td v-if="showColumn('date')">
|
||||||
|
<template v-if="!showColumn('name') && !showColumn('buy') && !showColumn('sell')">Total</template>
|
||||||
|
</td>
|
||||||
|
<td v-if="showColumn('price')" class="text-right">
|
||||||
|
<template v-if="total.sameItem">
|
||||||
|
{{ formatIsk(total.price) }}
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td v-if="showColumn('remaining')" class="text-right">
|
||||||
|
<template v-if="total.sameItem">
|
||||||
|
{{ total.remaining }}/{{ total.quantity }}
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td v-if="showColumn('precentProfit')" class="text-right">
|
||||||
|
<template v-if="total.sameItem">
|
||||||
|
{{ percentFormater.format(total.precentProfit) }}
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td v-if="showColumn('iskProfit')" class="text-right">{{ formatIsk(total.iskProfit) }}</td>
|
||||||
|
<td v-if="showColumn('buttons')" />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</template>
|
||||||
|
<template #empty>
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<span>No items found</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VirtualScrollTable>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
div.end {
|
||||||
|
@apply justify-self-end ms-2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,10 +3,9 @@ import { Modal } from '@/components';
|
|||||||
import {formatIsk} from '@/formaters';
|
import {formatIsk} from '@/formaters';
|
||||||
import {MarketType, MarketTypeLabel} from '@/market';
|
import {MarketType, MarketTypeLabel} from '@/market';
|
||||||
import {ref} from 'vue';
|
import {ref} from 'vue';
|
||||||
import { useTrackedItemStore } from './track';
|
import {useAcquiredTypesStore} from './acquisition';
|
||||||
|
|
||||||
|
const acquiredTypesStore = useAcquiredTypesStore();
|
||||||
const trackedItemStore = useTrackedItemStore();
|
|
||||||
|
|
||||||
const modalOpen = ref<boolean>(false);
|
const modalOpen = ref<boolean>(false);
|
||||||
const type = ref<MarketType>();
|
const type = ref<MarketType>();
|
||||||
@@ -38,7 +37,7 @@ const add = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
trackedItemStore.addTrackedItem(id, count.value, price.value);
|
acquiredTypesStore.addAcquiredType(id, count.value, price.value);
|
||||||
modalOpen.value = false;
|
modalOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Modal } from '@/components';
|
||||||
|
import { MarketType, MarketTypeLabel } from '@/market';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { AcquiredType, acquiredTypesToSorted } from './AcquiredType';
|
||||||
|
import { useAcquiredTypesStore } from './acquisition';
|
||||||
|
|
||||||
|
|
||||||
|
const acquiredTypesStore = useAcquiredTypesStore();
|
||||||
|
|
||||||
|
const modalOpen = ref<boolean>(false);
|
||||||
|
const type = ref<MarketType>();
|
||||||
|
const count = ref(1);
|
||||||
|
const types = ref<AcquiredType[]>([]);
|
||||||
|
|
||||||
|
const open = (t: AcquiredType[]) => {
|
||||||
|
if (t.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
types.value = acquiredTypesToSorted(t);
|
||||||
|
type.value = t[0].type;
|
||||||
|
count.value = 1;
|
||||||
|
modalOpen.value = true;
|
||||||
|
}
|
||||||
|
const remove = async () => {
|
||||||
|
if (!types.value) {
|
||||||
|
modalOpen.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let c = count.value;
|
||||||
|
|
||||||
|
for (const type of types.value) {
|
||||||
|
const remaining = type.remaining;
|
||||||
|
|
||||||
|
await acquiredTypesStore.removeAcquiredType(type.id, c);
|
||||||
|
c -= remaining;
|
||||||
|
if (c <= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
modalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal v-model:open="modalOpen">
|
||||||
|
<div class="bg-slate-800 rounded">
|
||||||
|
<MarketTypeLabel v-if="type" class="m-1" :id="type.id" :name="type.name" hideCopy />
|
||||||
|
<hr />
|
||||||
|
<div class="flex p-4">
|
||||||
|
<div class="flex me-2 mb-auto">
|
||||||
|
<span>Count: </span>
|
||||||
|
<div class="ms-2">
|
||||||
|
<input type="number" min="0" step="1" v-model="count" @keyup.enter="remove" />
|
||||||
|
<div>
|
||||||
|
<button class="px-2 mt-2 bg-slate-600 hover:bg-slate-700 border rounded cursor-pointer" @click="count = types.reduce((acc, t) => acc + t.remaining, 0)">All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="mb-auto" @click="remove">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import {defineStore} from "pinia";
|
||||||
|
import {computed, ref} from "vue";
|
||||||
|
import {acquisitionApi} from "@/mammon";
|
||||||
|
import {AcquisitionResponse, AcquisitionResponseSourceEnum} from "@/generated/mammon";
|
||||||
|
|
||||||
|
export type AcquiredTypeSource = 'bo' | 'so' | 'prod' | 'misc';
|
||||||
|
|
||||||
|
export type RawAcquiredType = {
|
||||||
|
id: string;
|
||||||
|
type: number;
|
||||||
|
quantity: number;
|
||||||
|
remaining: number;
|
||||||
|
price: number;
|
||||||
|
date: Date;
|
||||||
|
source: AcquisitionResponseSourceEnum;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toAcquiredType = (a: AcquisitionResponse): RawAcquiredType => ({
|
||||||
|
id: a.acquisitionId,
|
||||||
|
type: a.marketTypeId,
|
||||||
|
quantity: a.quantity,
|
||||||
|
remaining: a.remaining,
|
||||||
|
price: a.unitCost,
|
||||||
|
date: new Date(a.datetime),
|
||||||
|
source: a.source,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useAcquiredTypesStore = defineStore('market-acquisition', () => {
|
||||||
|
const acquiredTypes = ref<RawAcquiredType[]>([]);
|
||||||
|
|
||||||
|
const types = computed(() => acquiredTypes.value.filter(item => item.remaining > 0));
|
||||||
|
|
||||||
|
// Display-only: the backend exposes no write endpoint yet, so buy/sell are no-ops.
|
||||||
|
const addAcquiredType = async (_type: number, _quantity: number, _price: number, _source?: AcquiredTypeSource) => {};
|
||||||
|
const removeAcquiredType = async (_id: string, _quantity: number) => {};
|
||||||
|
|
||||||
|
const refresh = () => acquisitionApi.findAllAcquisitions()
|
||||||
|
.then(response => acquiredTypes.value = response.data.map(toAcquiredType));
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
return { acquiredTypes: types, addAcquiredType, removeAcquiredType, refresh };
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export * from './AcquiredType';
|
||||||
|
export * from './acquisition';
|
||||||
|
|
||||||
|
export { default as AcquisitionResultTable } from './AcquisitionResultTable.vue';
|
||||||
|
export { default as BuyModal } from './BuyModal.vue';
|
||||||
|
export { default as SellModal } from './SellModal.vue';
|
||||||
|
|
||||||
@@ -1,46 +1,44 @@
|
|||||||
import {defineStore} from 'pinia';
|
import {defineStore} from 'pinia';
|
||||||
import { ref } from 'vue';
|
import {RegionalMarketCache} from '../RegionalMarketCache';
|
||||||
|
import {jitaId} from '../market';
|
||||||
import {MarketType} from "../type";
|
import {MarketType} from "../type";
|
||||||
import {MarketTypePrice} from './MarketTypePrice';
|
import {MarketTypePrice} from './MarketTypePrice';
|
||||||
import { getEvepraisalPrices } from './evepraisal';
|
import {getMammonPrices} from './mammon';
|
||||||
import { getfuzzworkPrices } from './fuzzwork';
|
|
||||||
|
|
||||||
type MarketTypePriceCache = {
|
const CACHE_DURATION = 1000 * 60 * 5; // 5 minutes
|
||||||
price: MarketTypePrice,
|
const BATCH_SIZE = 100;
|
||||||
date: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheDuration = 1000 * 60 * 5; // 5 minutes
|
|
||||||
const priceGetters = {
|
|
||||||
evepraisal: getEvepraisalPrices,
|
|
||||||
fuzzwork: getfuzzworkPrices
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useApraisalStore = defineStore('appraisal', () => {
|
export const useApraisalStore = defineStore('appraisal', () => {
|
||||||
const cache = ref<Record<number, MarketTypePriceCache>>({});
|
const cache: RegionalMarketCache<MarketTypePrice> = new RegionalMarketCache(CACHE_DURATION);
|
||||||
|
|
||||||
const getPricesUncached = priceGetters.fuzzwork;
|
const getPricesUncached = getMammonPrices;
|
||||||
|
|
||||||
const getPrice = async (type: MarketType): Promise<MarketTypePrice> => (await getPrices([type]))[0];
|
const getPrice = async (type: MarketType, regionId?: number): Promise<MarketTypePrice> => (await getPrices([type], regionId))[0];
|
||||||
const getPrices = async (types: MarketType[]): Promise<MarketTypePrice[]> => {
|
const getPrices = async (types: MarketType[], regionId?: number): Promise<MarketTypePrice[]> => {
|
||||||
const now = new Date();
|
|
||||||
const cached: MarketTypePrice[] = [];
|
const cached: MarketTypePrice[] = [];
|
||||||
const uncached: MarketType[] = [];
|
const uncached: MarketType[] = [];
|
||||||
|
const rId = regionId ?? jitaId;
|
||||||
|
|
||||||
types.forEach(t => {
|
types.forEach(t => {
|
||||||
const cachedPrice = cache.value[t.id];
|
const cachedPrice = cache.get(rId, t.id);
|
||||||
|
|
||||||
if (cachedPrice && now.getTime() - cachedPrice.date.getTime() < cacheDuration) {
|
if (cachedPrice) {
|
||||||
cached.push(cachedPrice.price);
|
cached.push(cachedPrice);
|
||||||
} else {
|
} else {
|
||||||
uncached.push(t);
|
uncached.push(t);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (uncached.length > 0) {
|
if (uncached.length > 0) {
|
||||||
const prices = await getPricesUncached(uncached);
|
const batches: Promise<MarketTypePrice[]>[] = [];
|
||||||
|
|
||||||
prices.forEach(p => cache.value[p.type.id] = { price: p, date: now });
|
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
|
||||||
|
batches.push(getPricesUncached(uncached.slice(i, i + BATCH_SIZE)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const prices = (await Promise.all(batches)).flat();
|
||||||
|
|
||||||
|
prices.forEach(p => cache.set(rId, p.type.id, p));
|
||||||
return [ ...cached, ...prices ];
|
return [ ...cached, ...prices ];
|
||||||
}
|
}
|
||||||
return cached;
|
return cached;
|
||||||
|
|||||||
@@ -1,31 +1,11 @@
|
|||||||
import {logResource} from '@/service';
|
import {logResource} from '@/service';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { MarketType } from "../type";
|
|
||||||
import { PriceGetter } from './MarketTypePrice';
|
|
||||||
|
|
||||||
export const evepraisalAxiosInstance = axios.create({
|
export const evepraisalAxiosInstance = axios.create({
|
||||||
baseURL: '/evepraisal/',
|
baseURL: import.meta.env.VITE_EVEPRAISAL_URL,
|
||||||
headers: {
|
headers: {
|
||||||
'accept': 'application/json',
|
'accept': 'application/json',
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
logResource(evepraisalAxiosInstance)
|
logResource(evepraisalAxiosInstance)
|
||||||
|
|
||||||
const batchSize = 100;
|
|
||||||
|
|
||||||
export const getEvepraisalPrices: PriceGetter = async types => {
|
|
||||||
const batches = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < types.length; i += batchSize) {
|
|
||||||
batches.push(evepraisalAxiosInstance.post(`/appraisal.json?market=jita&persist=no&raw_textarea=${types.slice(i, i + batchSize).map(t => t.name).join("%0A")}`));
|
|
||||||
}
|
|
||||||
return (await Promise.all(batches))
|
|
||||||
.flatMap(b => b.data.appraisal.items)
|
|
||||||
.map((item: any) => ({
|
|
||||||
type: types.find(t => t.name === item.typeName) as MarketType,
|
|
||||||
buy: item.prices.buy.max,
|
|
||||||
sell: item.prices.sell.min,
|
|
||||||
orderCount: item.prices.all.order_count
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { logResource } from '@/service';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { MarketType } from "../type";
|
|
||||||
import { PriceGetter } from './MarketTypePrice';
|
|
||||||
|
|
||||||
export const fuzzworkAxiosInstance = axios.create({
|
|
||||||
baseURL: '/fuzzwork/',
|
|
||||||
headers: {
|
|
||||||
'accept': 'application/json',
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
})
|
|
||||||
logResource(fuzzworkAxiosInstance)
|
|
||||||
|
|
||||||
const batchSize = 100;
|
|
||||||
|
|
||||||
export const getfuzzworkPrices: PriceGetter = async types => {
|
|
||||||
const batches = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < types.length; i += batchSize) {
|
|
||||||
batches.push(fuzzworkAxiosInstance.post(`/aggregates/?station=60003760&types=${types.slice(i, i + batchSize).map(t => t.id).join(",")}`));
|
|
||||||
}
|
|
||||||
return (await Promise.all(batches))
|
|
||||||
.flatMap(b => Object.entries(b.data))
|
|
||||||
.map(entry => {
|
|
||||||
const id = parseInt(entry[0]);
|
|
||||||
const prices = entry[1] as any;
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: types.find(t => t.id === id) as MarketType,
|
|
||||||
buy: parseFloat(prices.buy.max),
|
|
||||||
sell: parseFloat(prices.sell.min),
|
|
||||||
orderCount: parseInt(prices.buy.order_count) + parseInt(prices.sell.order_count)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import {marketApi} from '@/mammon/mammonService';
|
||||||
|
import {MarketTypePrice, PriceGetter} from './MarketTypePrice';
|
||||||
|
|
||||||
|
export const getMammonPrices: PriceGetter = async types => {
|
||||||
|
if (types.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const typesById = new Map(types.map(t => [t.id, t]));
|
||||||
|
const response = await marketApi.currentPrices(types.map(t => t.id));
|
||||||
|
|
||||||
|
return response.data.reduce<MarketTypePrice[]>((prices, p) => {
|
||||||
|
const type = typesById.get(p.marketTypeId);
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
prices.push({ type, buy: p.buy, sell: p.sell, orderCount: p.orderCount });
|
||||||
|
}
|
||||||
|
return prices;
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MarketOrderHistory } from "@/market";
|
import { MarketHistory } from "@/market";
|
||||||
|
|
||||||
export type HistoryQuartils = {
|
export type HistoryQuartils = {
|
||||||
totalVolume: number,
|
totalVolume: number,
|
||||||
@@ -7,7 +7,7 @@ export type HistoryQuartils = {
|
|||||||
q3: number,
|
q3: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getHistoryQuartils = (history: MarketOrderHistory[], days?: number): HistoryQuartils => {
|
export const getHistoryQuartils = (history: MarketHistory[], days?: number): HistoryQuartils => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const volumes = history
|
const volumes = history
|
||||||
@@ -51,7 +51,7 @@ export const getHistoryQuartils = (history: MarketOrderHistory[], days?: number)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const estimateVolume = (history: MarketOrderHistory): number => {
|
const estimateVolume = (history: MarketHistory): number => {
|
||||||
if (history.volume === 0) {
|
if (history.volume === 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { MarketHistoryResponse } from "@/generated/mammon";
|
||||||
|
import { marketApi } from "@/mammon";
|
||||||
|
|
||||||
|
export type MarketHistory = MarketHistoryResponse;
|
||||||
|
|
||||||
|
export const getHistory = async (typeId: number): Promise<MarketHistory[]> =>
|
||||||
|
(await marketApi.findHistory(typeId)).data;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './MarketHistory';
|
||||||
|
export * from './HistoryQuartils';
|
||||||
+4
-1
@@ -1,7 +1,10 @@
|
|||||||
|
export * from './RegionalMarketCache';
|
||||||
|
export * from './history';
|
||||||
export * from './tax';
|
export * from './tax';
|
||||||
export * from './type';
|
export * from './type';
|
||||||
|
|
||||||
export * from './MarketOrderHistory';
|
|
||||||
export * from './appraisal';
|
export * from './appraisal';
|
||||||
export * from './market';
|
export * from './market';
|
||||||
|
|
||||||
|
export { default as IskLabel } from './IskLabel.vue';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
|
|
||||||
export const jitaId = 10000002;
|
export const jitaId = 10000002;
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {SliderCheckbox} from '@/components';
|
import {SliderCheckbox} from '@/components';
|
||||||
import { SortableHeader, useSort } from '@/components/table';
|
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
|
||||||
import { formatIsk, percentFormater } from '@/formaters';
|
import {formatIsk, percentFormater} from "@/formaters";
|
||||||
import { MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore } from "@/market";
|
import {MarketType, MarketTypeLabel} from "@/market";
|
||||||
import { BookmarkSlashIcon, ShoppingCartIcon } from '@heroicons/vue/24/outline';
|
import {ShoppingCartIcon} from '@heroicons/vue/24/outline';
|
||||||
import {useStorage} from '@vueuse/core';
|
import {useStorage} from '@vueuse/core';
|
||||||
import {computed, ref} from 'vue';
|
import {computed, ref} from 'vue';
|
||||||
import { ScanResult, getHistoryQuartils } from '.';
|
import {useAcquiredTypesStore} from '../acquisition';
|
||||||
|
import {ScanResult} from './scan';
|
||||||
|
|
||||||
type Result = {
|
type Result = {
|
||||||
type: MarketType;
|
type: MarketType;
|
||||||
@@ -17,6 +18,8 @@ type Result = {
|
|||||||
q1: number;
|
q1: number;
|
||||||
median: number;
|
median: number;
|
||||||
q3: number;
|
q3: number;
|
||||||
|
totalVolume: number;
|
||||||
|
acquisitions: number;
|
||||||
profit: number;
|
profit: number;
|
||||||
score: number;
|
score: number;
|
||||||
}
|
}
|
||||||
@@ -24,17 +27,19 @@ type Result = {
|
|||||||
interface Props {
|
interface Props {
|
||||||
items?: ScanResult[];
|
items?: ScanResult[];
|
||||||
infoOnly?: boolean;
|
infoOnly?: boolean;
|
||||||
ignoredColums?: string[];
|
ignoredColums?: string[] | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'buy', type: MarketType, buy: number, sell: number): void;
|
(e: 'buy', type: MarketType, buy: number, sell: number): void;
|
||||||
(e: 'remove', type: MarketType): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scoreFormater = new Intl.NumberFormat("en-US", {
|
const scoreFormater = new Intl.NumberFormat("en-US", {
|
||||||
maximumFractionDigits: 0
|
maximumFractionDigits: 0
|
||||||
});
|
});
|
||||||
|
const volumeFormater = new Intl.NumberFormat("en-US", {
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
});
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
items: () => [],
|
items: () => [],
|
||||||
@@ -43,24 +48,25 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
});
|
});
|
||||||
defineEmits<Emits>();
|
defineEmits<Emits>();
|
||||||
|
|
||||||
const marketTaxStore = useMarketTaxStore();
|
const acquiredTypesStore = useAcquiredTypesStore();
|
||||||
|
|
||||||
const days = useStorage('market-scan-days', 365);
|
|
||||||
const threshold = useStorage('market-scan-threshold', 10);
|
const threshold = useStorage('market-scan-threshold', 10);
|
||||||
const filter = ref("");
|
const filter = ref("");
|
||||||
const onlyCheap = ref(false);
|
const onlyCheap = ref(false);
|
||||||
const columnsToIgnore = computed(() => {
|
const columnsToIgnore = computed(() => {
|
||||||
if (props.infoOnly && !props.ignoredColums.includes('buttons')) {
|
const ic = typeof props.ignoredColums === 'string' ? [props.ignoredColums] : props.ignoredColums;
|
||||||
return [...props.ignoredColums, 'buttons'];
|
|
||||||
|
if (props.infoOnly && !ic.includes('buttons')) {
|
||||||
|
return [...ic, 'buttons'];
|
||||||
}
|
}
|
||||||
return props.ignoredColums;
|
return ic;
|
||||||
});
|
});
|
||||||
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => props.items
|
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => props.items
|
||||||
.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase()))
|
.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase()))
|
||||||
.map(r => {
|
.map(r => {
|
||||||
const quartils = getHistoryQuartils(r.history, days.value);
|
const acquisitions = columnsToIgnore.value.includes('acquisitions') ? 0 : acquiredTypesStore.acquiredTypes
|
||||||
const profit = quartils.q1 === 0 || quartils.q3 === 0 ? 0 : marketTaxStore.calculateProfit(quartils.q1, quartils.q3);
|
.filter(t => t.type === r.type.id)
|
||||||
const score = profit <= 0 ? 0 : Math.sqrt((Math.pow(quartils.totalVolume, 1.1) * Math.pow(quartils.q1, 1.2) * Math.pow(profit, 0.5) * Math.pow(Math.max(1, r.orderCount), -0.7)) / days.value);
|
.reduce((a, b) => a + b.remaining, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: r.type,
|
type: r.type,
|
||||||
@@ -68,11 +74,13 @@ const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() =>
|
|||||||
name: r.type.name,
|
name: r.type.name,
|
||||||
buy: r.buy,
|
buy: r.buy,
|
||||||
sell: r.sell,
|
sell: r.sell,
|
||||||
q1: quartils.q1,
|
q1: r.q1,
|
||||||
median: quartils.median,
|
median: r.median,
|
||||||
q3: quartils.q3,
|
q3: r.q3,
|
||||||
profit,
|
totalVolume: r.totalVolume,
|
||||||
score
|
acquisitions,
|
||||||
|
profit: r.profit,
|
||||||
|
score: r.score
|
||||||
};
|
};
|
||||||
}).filter(r => !onlyCheap.value || (r.buy <= r.q1 && r.profit >= (threshold.value / 100)))), {
|
}).filter(r => !onlyCheap.value || (r.buy <= r.q1 && r.profit >= (threshold.value / 100)))), {
|
||||||
defaultSortKey: 'score',
|
defaultSortKey: 'score',
|
||||||
@@ -96,25 +104,21 @@ const getLineColor = (result: Result) => {
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="!infoOnly" class="flex mb-2 mt-4">
|
<div v-if="!infoOnly" class="flex mb-2 mt-4">
|
||||||
<div class="flex justify-self-end ms-auto">
|
<div class="flex justify-self-end ms-auto">
|
||||||
<TaxInput />
|
|
||||||
<div class="end">
|
<div class="end">
|
||||||
<span>Profit Threshold: </span>
|
<span>Profit Threshold: </span>
|
||||||
<input type="number" min="0" max="1000" step="1" v-model="threshold" />
|
<input type="number" min="0" max="1000" step="1" v-model="threshold" />
|
||||||
</div>
|
</div>
|
||||||
<div class="end">
|
|
||||||
<span>Days: </span>
|
|
||||||
<input type="number" min="1" max="365" step="1" v-model="days" />
|
|
||||||
</div>
|
|
||||||
<div class="end flex">
|
<div class="end flex">
|
||||||
<SliderCheckbox class="me-1" v-model="onlyCheap" /> Show only cheap items
|
<SliderCheckbox class="me-1" v-model="onlyCheap" /> Show only cheap items
|
||||||
</div>
|
</div>
|
||||||
<div class="end">
|
<div class="end">
|
||||||
<span>Filter: </span>
|
<span>Filter: </span>
|
||||||
<input type="search" class="w-96" v-model="filter" >
|
<input type="search" class="w-96" v-model="filter" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem">
|
||||||
|
<template #default="{ list }">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
|
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
|
||||||
@@ -123,34 +127,44 @@ const getLineColor = (result: Result) => {
|
|||||||
<SortableHeader v-bind="headerProps" sortKey="q1">Q1</SortableHeader>
|
<SortableHeader v-bind="headerProps" sortKey="q1">Q1</SortableHeader>
|
||||||
<SortableHeader v-bind="headerProps" sortKey="median">Median</SortableHeader>
|
<SortableHeader v-bind="headerProps" sortKey="median">Median</SortableHeader>
|
||||||
<SortableHeader v-bind="headerProps" sortKey="q3">Q3</SortableHeader>
|
<SortableHeader v-bind="headerProps" sortKey="q3">Q3</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="totalVolume">Volume</SortableHeader>
|
||||||
<SortableHeader v-bind="headerProps" sortKey="profit">Profit</SortableHeader>
|
<SortableHeader v-bind="headerProps" sortKey="profit">Profit</SortableHeader>
|
||||||
<SortableHeader v-bind="headerProps" sortKey="score">Score</SortableHeader>
|
<SortableHeader v-bind="headerProps" sortKey="score">Score</SortableHeader>
|
||||||
<SortableHeader v-bind="headerProps" sortKey="buttons" unsortable></SortableHeader>
|
<SortableHeader v-bind="headerProps" sortKey="acquisitions">Acquisitions</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="buttons" unsortable />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="r in sortedArray" :key="r.typeID" :class="getLineColor(r)">
|
<tr v-for="r in list" :key="r.data.typeID" :class="getLineColor(r.data)">
|
||||||
<td v-if="showColumn('name')">
|
<td v-if="showColumn('name')">
|
||||||
<MarketTypeLabel :id="r.typeID" :name="r.name" />
|
<MarketTypeLabel :id="r.data.typeID" :name="r.data.name" />
|
||||||
</td>
|
</td>
|
||||||
<td v-if="showColumn('buy')" class="text-right">{{ formatIsk(r.buy) }}</td>
|
<td v-if="showColumn('buy')" class="text-right">{{ formatIsk(r.data.buy) }}</td>
|
||||||
<td v-if="showColumn('sell')" class="text-right">{{ formatIsk(r.sell) }}</td>
|
<td v-if="showColumn('sell')" class="text-right">{{ formatIsk(r.data.sell) }}</td>
|
||||||
<td v-if="showColumn('q1')" class="text-right">{{ formatIsk(r.q1) }}</td>
|
<td v-if="showColumn('q1')" class="text-right">{{ formatIsk(r.data.q1) }}</td>
|
||||||
<td v-if="showColumn('median')" class="text-right">{{ formatIsk(r.median) }}</td>
|
<td v-if="showColumn('median')" class="text-right">{{ formatIsk(r.data.median) }}</td>
|
||||||
<td v-if="showColumn('q3')" class="text-right">{{ formatIsk(r.q3) }}</td>
|
<td v-if="showColumn('q3')" class="text-right">{{ formatIsk(r.data.q3) }}</td>
|
||||||
<td v-if="showColumn('profit')" class="text-right">{{ percentFormater.format(r.profit) }}</td>
|
<td v-if="showColumn('totalVolume')" class="text-right">{{ volumeFormater.format(r.data.totalVolume) }}</td>
|
||||||
<td v-if="showColumn('score')" class="text-right">{{ scoreFormater.format(r.score) }}</td>
|
<td v-if="showColumn('profit')" class="text-right">{{ percentFormater.format(r.data.profit) }}</td>
|
||||||
|
<td v-if="showColumn('score')" class="text-right">{{ scoreFormater.format(r.data.score) }}</td>
|
||||||
|
<td v-if="showColumn('acquisitions')" class="text-right">{{ r.data.acquisitions }}</td>
|
||||||
<td v-if="showColumn('buttons')" class="text-right">
|
<td v-if="showColumn('buttons')" class="text-right">
|
||||||
<button class="btn-icon me-1" @click="$emit('buy', r.type, r.buy, r.sell)"><ShoppingCartIcon /></button>
|
<button class="btn-icon me-1" title="Add acquisitions" @click="$emit('buy', r.data.type, r.data.buy, r.data.sell)"><ShoppingCartIcon /></button>
|
||||||
<button class="btn-icon me-1" @click="$emit('remove', r.type)"><BookmarkSlashIcon /></button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</template>
|
||||||
|
<template #empty>
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<span>No items found</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VirtualScrollTable>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
div.end {
|
div.end {
|
||||||
@apply justify-self-end ms-2;
|
@apply justify-self-end ms-2;
|
||||||
}
|
}
|
||||||
</style>@/components/table
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
export * from './HistoryQuartils';
|
|
||||||
export * from './scan';
|
export * from './scan';
|
||||||
|
|
||||||
export { default as ScanResultTable } from './ScanResultTable.vue';
|
export { default as ScanResultTable } from './ScanResultTable.vue';
|
||||||
|
|
||||||
|
|||||||
+20
-50
@@ -1,56 +1,26 @@
|
|||||||
import { MarketOrderHistory, MarketType, MarketTypePrice, getHistory, jitaId } from "@/market";
|
import { MarketType } from "@/market";
|
||||||
import { usePocketBase, watchCollection } from "@/pocketbase";
|
import { MarketScanResponse } from "@/generated/mammon";
|
||||||
import { defineStore } from "pinia";
|
|
||||||
import { RecordModel } from "pocketbase";
|
|
||||||
import { computed, onMounted, ref } from "vue";
|
|
||||||
|
|
||||||
export type ScanResult = {
|
export type ScanResult = {
|
||||||
type: MarketType;
|
type: MarketType;
|
||||||
history: MarketOrderHistory[];
|
buy: number;
|
||||||
buy: number,
|
sell: number;
|
||||||
sell: number,
|
q1: number;
|
||||||
orderCount: number,
|
median: number;
|
||||||
|
q3: number;
|
||||||
|
totalVolume: number;
|
||||||
|
profit: number;
|
||||||
|
score: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MarketScan extends RecordModel {
|
export const toScanResult = (res: MarketScanResponse, type: MarketType): ScanResult => ({
|
||||||
owner: string;
|
type,
|
||||||
types: number[];
|
buy: res.buy,
|
||||||
};
|
sell: res.sell,
|
||||||
|
q1: res.q1,
|
||||||
const marketScans = 'marketScans';
|
median: res.median,
|
||||||
|
q3: res.q3,
|
||||||
export const useMarketScanStore = defineStore(marketScans, () => {
|
totalVolume: res.totalVolume,
|
||||||
const pb = usePocketBase();
|
profit: res.profit,
|
||||||
const marketScan = ref<MarketScan>();
|
score: res.score,
|
||||||
|
|
||||||
const types = computed(() => marketScan.value?.types ?? []);
|
|
||||||
const setTypes = async (types: number[]) => {
|
|
||||||
if (marketScan.value?.id) {
|
|
||||||
marketScan.value = await pb.collection(marketScans).update(marketScan.value.id, { owner: pb.authStore.model!.id, types });
|
|
||||||
} else {
|
|
||||||
marketScan.value = await pb.collection(marketScans).create({ owner: pb.authStore.model!.id, types });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const addType = async (type: number) => {
|
|
||||||
if (!types.value.includes(type)) {
|
|
||||||
await setTypes([...types.value, type]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const removeType = async (type: number) => {
|
|
||||||
if (types.value.includes(type)) {
|
|
||||||
await setTypes(types.value.filter(t => t !== type));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watchCollection<MarketScan>(marketScans, '*', data => {
|
|
||||||
if (data.action === 'delete') {
|
|
||||||
marketScan.value = undefined;
|
|
||||||
} else if (!marketScan.value || data.record.id === marketScan.value.id) {
|
|
||||||
marketScan.value = data.record;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
onMounted(async () => marketScan.value = await pb.collection(marketScans).getFirstListItem<MarketScan>('').catch(() => undefined));
|
|
||||||
return { types, setTypes, addType, removeType };
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createResult = async (id: number, price: MarketTypePrice): Promise<ScanResult> => ({ history: await getHistory(jitaId, id), ...price });
|
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from "pinia";
|
|
||||||
import {useMarketTaxStore} from "./tax";
|
import {useMarketTaxStore} from "./tax";
|
||||||
|
|
||||||
const { brokerFee, scc } = storeToRefs(useMarketTaxStore());
|
const marketTaxStore = useMarketTaxStore();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="end">
|
<div class="end">
|
||||||
<span>Broker Fee: </span>
|
<span>Broker Fee: </span>
|
||||||
<input type="number" min="1" max="3" step="0.01" v-model="brokerFee" />
|
<input type="number" min="1" max="3" step="0.01" v-model="marketTaxStore.brokerFee" />
|
||||||
</div>
|
</div>
|
||||||
<div class="end">
|
<div class="end">
|
||||||
<span>SCC: </span>
|
<span>SCC: </span>
|
||||||
<input type="number" min="3.6" max="8" step="0.01" v-model="scc" >
|
<input type="number" min="3.6" max="8" step="0.01" v-model="marketTaxStore.scc" >
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
div.end {
|
div.end {
|
||||||
@apply justify-self-end ms-2;
|
@apply justify-self-end ms-2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Modal } from '@/components';
|
|
||||||
import { MarketType, MarketTypeLabel } from '@/market';
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { useTrackedItemStore } from './track';
|
|
||||||
|
|
||||||
|
|
||||||
const trackedItemStore = useTrackedItemStore();
|
|
||||||
|
|
||||||
const modalOpen = ref<boolean>(false);
|
|
||||||
const type = ref<MarketType>();
|
|
||||||
const count = ref(1);
|
|
||||||
|
|
||||||
const open = (t: MarketType) => {
|
|
||||||
type.value = t;
|
|
||||||
count.value = 1;
|
|
||||||
modalOpen.value = true;
|
|
||||||
}
|
|
||||||
const remove = () => {
|
|
||||||
const id = type.value?.id;
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
modalOpen.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
trackedItemStore.removeTrackedItem(id, count.value);
|
|
||||||
modalOpen.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({ open });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Modal v-model:open="modalOpen">
|
|
||||||
<div class="bg-slate-800 rounded">
|
|
||||||
<MarketTypeLabel v-if="type" class="m-1" :id="type.id" :name="type.name" hideCopy />
|
|
||||||
<hr />
|
|
||||||
<div class="flex p-4">
|
|
||||||
<div class="flex me-2 mb-auto">
|
|
||||||
<span>Count: </span>
|
|
||||||
<input class="ms-2" type="number" min="0" step="1" v-model="count" @keyup.enter="remove" />
|
|
||||||
</div>
|
|
||||||
<button class="mb-auto" @click="remove">Remove</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</template>
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { SortableHeader, useSort } from '@/components/table';
|
|
||||||
import { formatIsk, percentFormater } from '@/formaters';
|
|
||||||
import { MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore } from "@/market";
|
|
||||||
import { MinusIcon, PlusIcon } from '@heroicons/vue/24/outline';
|
|
||||||
import { useStorage } from '@vueuse/core';
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { TrackedItem } from '.';
|
|
||||||
import TrackQuantilsTooltip from './TrackQuantilsTooltip.vue';
|
|
||||||
|
|
||||||
type Result = {
|
|
||||||
type: MarketType;
|
|
||||||
typeID: number;
|
|
||||||
name: string;
|
|
||||||
buy: number;
|
|
||||||
sell: number;
|
|
||||||
price: number;
|
|
||||||
count: number;
|
|
||||||
precentProfit: number;
|
|
||||||
iskProfit: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
items?: TrackedItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: 'buy', type: MarketType, price: number, buy: number, sell: number): void;
|
|
||||||
(e: 'sell', type: MarketType): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
items: () => []
|
|
||||||
});
|
|
||||||
defineEmits<Emits>();
|
|
||||||
|
|
||||||
const marketTaxStore = useMarketTaxStore();
|
|
||||||
|
|
||||||
const threshold = useStorage('market-track-threshold', 10);
|
|
||||||
const filter = ref("");
|
|
||||||
const { sortedArray, headerProps } = useSort<Result>(computed(() => props.items
|
|
||||||
.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase()))
|
|
||||||
.map(r => {
|
|
||||||
const precentProfit = marketTaxStore.calculateProfit(r.averagePrice, r.sell);
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: r.type,
|
|
||||||
typeID: r.type.id,
|
|
||||||
name: r.type.name,
|
|
||||||
buy: r.buy,
|
|
||||||
sell: r.sell,
|
|
||||||
price: r.averagePrice,
|
|
||||||
count: r.count,
|
|
||||||
precentProfit,
|
|
||||||
iskProfit: r.averagePrice * precentProfit * r.count
|
|
||||||
};
|
|
||||||
})), {
|
|
||||||
defaultSortKey: 'precentProfit',
|
|
||||||
defaultSortDirection: 'desc'
|
|
||||||
})
|
|
||||||
const getLineColor = (result: Result) => {
|
|
||||||
if (result.precentProfit >= (threshold.value / 100)) {
|
|
||||||
return 'line-green';
|
|
||||||
} else if (result.precentProfit < 0) {
|
|
||||||
return 'line-red';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex">
|
|
||||||
<div class="flex justify-self-end mb-2 mt-4 ms-auto">
|
|
||||||
<TaxInput />
|
|
||||||
<div class="end">
|
|
||||||
<span>Profit Threshold: </span>
|
|
||||||
<input type="number" min="0" max="1000" step="1" v-model="threshold" />
|
|
||||||
</div>
|
|
||||||
<div class="end">
|
|
||||||
<span>Filter: </span>
|
|
||||||
<input type="search" class="w-96" v-model="filter" >
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
|
|
||||||
<SortableHeader v-bind="headerProps" sortKey="buy">Buy</SortableHeader>
|
|
||||||
<SortableHeader v-bind="headerProps" sortKey="sell">Sell</SortableHeader>
|
|
||||||
<SortableHeader v-bind="headerProps" sortKey="price">Bought Price</SortableHeader>
|
|
||||||
<SortableHeader v-bind="headerProps" sortKey="count">Bought Amount</SortableHeader>
|
|
||||||
<SortableHeader v-bind="headerProps" sortKey="precentProfit">Profit (%)</SortableHeader>
|
|
||||||
<SortableHeader v-bind="headerProps" sortKey="iskProfit">Profit (ISK)</SortableHeader>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="r in sortedArray" :key="r.typeID" :class="getLineColor(r)">
|
|
||||||
<td>
|
|
||||||
<div class="flex">
|
|
||||||
<MarketTypeLabel :id="r.typeID" :name="r.name" />
|
|
||||||
<TrackQuantilsTooltip :id="r.typeID" :buy="r.buy" :sell="r.sell" />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-right">{{ formatIsk(r.buy) }}</td>
|
|
||||||
<td class="text-right">{{ formatIsk(r.sell) }}</td>
|
|
||||||
<td class="text-right">{{ formatIsk(r.price) }}</td>
|
|
||||||
<td class="text-right">{{ r.count }}</td>
|
|
||||||
<td class="text-right">{{ percentFormater.format(r.precentProfit) }}</td>
|
|
||||||
<td class="text-right">{{ formatIsk(r.iskProfit) }}</td>
|
|
||||||
<td class="text-right">
|
|
||||||
<button class="btn-icon me-1" @click="$emit('buy', r.type, r.price, r.buy, r.sell)"><PlusIcon /></button>
|
|
||||||
<button class="btn-icon me-1" @click="$emit('sell', r.type)"><MinusIcon /></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
|
||||||
div.end {
|
|
||||||
@apply justify-self-end ms-2;
|
|
||||||
}
|
|
||||||
</style>@/components/table
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { MarketType } from "@/market";
|
|
||||||
|
|
||||||
export type TrackedItem = {
|
|
||||||
type: MarketType;
|
|
||||||
count: number;
|
|
||||||
averagePrice: number;
|
|
||||||
buy: number,
|
|
||||||
sell: number
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export * from './TrackedItem';
|
|
||||||
export * from './track';
|
|
||||||
|
|
||||||
export { default as BuyModal } from './BuyModal.vue';
|
|
||||||
export { default as SellModal } from './SellModal.vue';
|
|
||||||
export { default as TrackResultTable } from './TrackResultTable.vue';
|
|
||||||
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { useCollection, usePocketBase } from "@/pocketbase";
|
|
||||||
import { createSharedComposable, useLocalStorage } from '@vueuse/core';
|
|
||||||
import { defineStore } from "pinia";
|
|
||||||
import { RecordModel } from "pocketbase";
|
|
||||||
import { computed } from "vue";
|
|
||||||
|
|
||||||
export type TrackedMarketItemStorage = {
|
|
||||||
typeID: number;
|
|
||||||
count: number;
|
|
||||||
averagePrice: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrackedMarketItem extends RecordModel {
|
|
||||||
owner: string;
|
|
||||||
typeID: number;
|
|
||||||
count: number;
|
|
||||||
averagePrice: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const marketTrackings = 'marketTrackings';
|
|
||||||
|
|
||||||
export const useTrackedItemsStorage = createSharedComposable(() => useLocalStorage<TrackedMarketItemStorage[]>('market-track-items', []));
|
|
||||||
|
|
||||||
export const useTrackedItemStore = defineStore(marketTrackings, () => {
|
|
||||||
const pb = usePocketBase();
|
|
||||||
const trackedItems = useCollection<TrackedMarketItem>(marketTrackings);
|
|
||||||
|
|
||||||
const items = computed(() => trackedItems);
|
|
||||||
const addTrackedItem = async (typeID: number, count: number, averagePrice: number) => {
|
|
||||||
const oldItem = trackedItems.value.find(i => i.typeID === typeID);
|
|
||||||
|
|
||||||
if (oldItem?.id) {
|
|
||||||
pb.collection(marketTrackings).update(oldItem.id, {
|
|
||||||
...oldItem,
|
|
||||||
count: count + oldItem.count,
|
|
||||||
averagePrice: ((averagePrice * count) + (oldItem.averagePrice * oldItem.count)) / (count + oldItem.count)
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
pb.collection(marketTrackings).create({ owner: pb.authStore.model!.id, typeID, count, averagePrice});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const removeTrackedItem = async (typeID: number, count: number) => {
|
|
||||||
const oldItem = trackedItems.value.find(i => i.typeID === typeID);
|
|
||||||
|
|
||||||
if (!oldItem?.id) {
|
|
||||||
return;
|
|
||||||
} else if (oldItem.count > count) {
|
|
||||||
pb.collection(marketTrackings).update(oldItem.id, {
|
|
||||||
...oldItem,
|
|
||||||
count: oldItem.count - count
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
pb.collection(marketTrackings).delete(oldItem.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { items, addTrackedItem, removeTrackedItem };
|
|
||||||
});
|
|
||||||
@@ -1,57 +1,40 @@
|
|||||||
import { marbasAxiosInstance } from "@/service";
|
import {marketApi} from '@/mammon/mammonService';
|
||||||
|
import type {MarketTypeResponse} from '@/generated/mammon';
|
||||||
|
|
||||||
export type MarketType = {
|
export type MarketType = MarketTypeResponse;
|
||||||
id: number;
|
|
||||||
group_id: number;
|
const cache = new Map<number, MarketType>(); // TODO move to pinia store
|
||||||
marketgroup_id: number;
|
|
||||||
name: string;
|
const BATCH_SIZE = 100;
|
||||||
published: boolean;
|
|
||||||
description: string;
|
const fetchTypes = async (ids: number[]): Promise<void> => {
|
||||||
basePrice: number;
|
const missing = ids.filter(id => !cache.has(id));
|
||||||
icon_id: number;
|
if (missing.length === 0) {
|
||||||
volume: number;
|
return;
|
||||||
portionSize: number;
|
|
||||||
}
|
}
|
||||||
|
const batches: Promise<MarketType[]>[] = [];
|
||||||
|
for (let i = 0; i < missing.length; i += BATCH_SIZE) {
|
||||||
|
batches.push(marketApi.findTypes(missing.slice(i, i + BATCH_SIZE)).then(r => r.data));
|
||||||
|
}
|
||||||
|
const results = await Promise.all(batches);
|
||||||
|
results.flat().forEach(t => cache.set(t.id, t));
|
||||||
|
};
|
||||||
|
|
||||||
export const getMarketType = async (type: string | number): Promise<MarketType> => (await getMarketTypes([type]))[0];
|
export const getMarketType = async (type: string | number): Promise<MarketType> => (await getMarketTypes([type]))[0];
|
||||||
export const getMarketTypes = async (types: (string | number)[]): Promise<MarketType[]> => {
|
export const getMarketTypes = async (types: (string | number)[]): Promise<MarketType[]> => {
|
||||||
if (types.length === 0) {
|
if (types.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
} else if (types.length === 1 && typeof types[0] === "number") {
|
|
||||||
return [(await marbasAxiosInstance.get<MarketType>(`/sde/types/${types[0]}/`)).data];
|
|
||||||
}
|
}
|
||||||
return (await marbasAxiosInstance.post<MarketType[]>("/api/types/search", types.map(t => {
|
const ids = types.filter((t): t is number => typeof t === 'number');
|
||||||
if (typeof t === "number") {
|
await fetchTypes(ids);
|
||||||
return { id: t };
|
return ids.map(id => cache.get(id)).filter((t): t is MarketType => t !== undefined);
|
||||||
} else {
|
|
||||||
return { name: t };
|
|
||||||
}
|
}
|
||||||
}))).data;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blueprintMarketGrous = [ // TODO add all groups
|
|
||||||
2,
|
|
||||||
2157,
|
|
||||||
2159,
|
|
||||||
2339,
|
|
||||||
2160,
|
|
||||||
211,
|
|
||||||
1016,
|
|
||||||
339,
|
|
||||||
2290,
|
|
||||||
357,
|
|
||||||
1530,
|
|
||||||
359,
|
|
||||||
1531,
|
|
||||||
1532,
|
|
||||||
1533,
|
|
||||||
358
|
|
||||||
]
|
|
||||||
|
|
||||||
export const searchMarketTypes = async (search: string): Promise<MarketType[]> => {
|
export const searchMarketTypes = async (search: string): Promise<MarketType[]> => {
|
||||||
return (await marbasAxiosInstance.post<MarketType[]>("/api/types/search", [{
|
if (search.length === 0) {
|
||||||
name__icontains: search,
|
return [];
|
||||||
marketgroup_id___not: null,
|
}
|
||||||
marketgroup_id__in___not: blueprintMarketGrous,
|
const types = await marketApi.searchTypes(search).then(r => r.data);
|
||||||
}])).data;
|
types.forEach(t => cache.set(t.id, t));
|
||||||
|
return types;
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {vOnClickOutside} from '@vueuse/components';
|
import {vOnClickOutside} from '@vueuse/components';
|
||||||
import { useVirtualList, useVModel } from '@vueuse/core';
|
import {useVirtualList} from '@vueuse/core';
|
||||||
import log from 'loglevel';
|
import log from 'loglevel';
|
||||||
import {nextTick, ref, watch, watchEffect} from 'vue';
|
import {nextTick, ref, watch, watchEffect} from 'vue';
|
||||||
import {MarketType, searchMarketTypes} from './MarketType';
|
import {MarketType, searchMarketTypes} from './MarketType';
|
||||||
import MarketTypeLabel from "./MarketTypeLabel.vue";
|
import MarketTypeLabel from "./MarketTypeLabel.vue";
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
modelValue?: MarketType;
|
|
||||||
}
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'update:modelValue', value?: MarketType): void;
|
|
||||||
(e: 'submit'): void;
|
(e: 'submit'): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const modelValue = defineModel<MarketType>();
|
||||||
const emit = defineEmits<Emits>();
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
const value = useVModel(props, 'modelValue', emit);
|
|
||||||
|
|
||||||
const isOpen = ref(false);
|
const isOpen = ref(false);
|
||||||
const name = ref('');
|
const name = ref('');
|
||||||
const suggestions = ref<MarketType[]>([]);
|
const suggestions = ref<MarketType[]>([]);
|
||||||
@@ -47,7 +40,7 @@ const moveUp = () => {
|
|||||||
}
|
}
|
||||||
const select = (type?: MarketType) => {
|
const select = (type?: MarketType) => {
|
||||||
log.debug('Select:', type);
|
log.debug('Select:', type);
|
||||||
value.value = type;
|
modelValue.value = type;
|
||||||
currentIndex.value = -1;
|
currentIndex.value = -1;
|
||||||
suggestions.value = [];
|
suggestions.value = [];
|
||||||
isOpen.value = false;
|
isOpen.value = false;
|
||||||
@@ -62,18 +55,18 @@ const submit = async () => {
|
|||||||
|
|
||||||
select(v);
|
select(v);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
} else if (props.modelValue === undefined && suggestions.value.length > 0) {
|
} else if (modelValue.value === undefined && suggestions.value.length > 0) {
|
||||||
select(suggestions.value[0]);
|
select(suggestions.value[0]);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.value === undefined) {
|
if (modelValue.value === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emit('submit');
|
emit('submit');
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.modelValue, async v => {
|
watch(() => modelValue.value, async v => {
|
||||||
if (v === undefined) {
|
if (v === undefined) {
|
||||||
name.value = '';
|
name.value = '';
|
||||||
} else {
|
} else {
|
||||||
@@ -96,10 +89,10 @@ watchEffect(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<div @click="() => isOpen = true" v-on-click-outside="() => isOpen = false">
|
<div @click="() => isOpen = true" v-on-click-outside="() => isOpen = false">
|
||||||
<div class="fake-input">
|
<div class="fake-input">
|
||||||
<img v-if="value?.id" :src="`https://images.evetech.net/types/${value.id}/icon`" />
|
<img v-if="modelValue?.id" :src="`https://images.evetech.net/types/${modelValue.id}/icon?size=32`" alt="" />
|
||||||
<input type="text" v-model="name" @keyup.enter="submit" @keyup.down="moveDown" @keyup.up="moveUp" />
|
<input type="text" v-model="name" @keyup.enter="submit" @keyup.down="moveDown" @keyup.up="moveUp" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="suggestions.length > 1" class="z-10 absolute w-96">
|
<div v-if="suggestions.length > 1" class="z-20 absolute w-96">
|
||||||
<div v-bind="containerProps" class="rounded-b" style="height: 300px">
|
<div v-bind="containerProps" class="rounded-b" style="height: 300px">
|
||||||
<div v-bind="wrapperProps">
|
<div v-bind="wrapperProps">
|
||||||
<div v-for="s in list" :key="s.index" class="hover:bg-slate-700" :class="{'bg-slate-500': s.index !== currentIndex, 'bg-emerald-500': s.index === currentIndex}" @click="select(s.data)">
|
<div v-for="s in list" :key="s.index" class="hover:bg-slate-700" :class="{'bg-slate-500': s.index !== currentIndex, 'bg-emerald-500': s.index === currentIndex}" @click="select(s.data)">
|
||||||
@@ -111,7 +104,8 @@ watchEffect(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
.fake-input {
|
.fake-input {
|
||||||
@apply w-96 flex border bg-slate-500 rounded px-1 py-0.5;
|
@apply w-96 flex border bg-slate-500 rounded px-1 py-0.5;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ClipboardButton} from '@/components';
|
import {ClipboardButton} from '@/components';
|
||||||
|
import {InformationCircleIcon} from '@heroicons/vue/24/outline';
|
||||||
|
import {routeNames} from '@/routes';
|
||||||
|
import {computedAsync} from "@vueuse/core";
|
||||||
|
import {getMarketType} from "@/market";
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -8,25 +12,39 @@ interface Props {
|
|||||||
hideCopy?: boolean;
|
hideCopy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
name: "",
|
name: "",
|
||||||
id: 0,
|
id: 0,
|
||||||
hideCopy: false
|
hideCopy: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const computedName = computedAsync<string>(async () => {
|
||||||
|
if (props.name) {
|
||||||
|
return props.name;
|
||||||
|
} else if (props.id) {
|
||||||
|
return await getMarketType(props.id).then(marketType => marketType.name);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}, "");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="id || name">
|
<div v-if="id || computedName" class="flex flex-row">
|
||||||
<img v-if="id" :src="`https://images.evetech.net/types/${id}/icon`" class="inline-block w-5 h-5 me-1" />
|
<img v-if="id" :src="`https://images.evetech.net/types/${id}/icon?size=32`" class="inline-block w-5 h-5 me-1 mt-1" alt="" />
|
||||||
<template v-if="name">
|
<template v-if="computedName">
|
||||||
{{ name }}
|
{{ computedName }}
|
||||||
<ClipboardButton v-if="!hideCopy" :value="name" />
|
<RouterLink v-if="id" :to="{ name: routeNames.marketTypes, params: { type: id } }" class="btn-icon ms-1 me-1 mt-1" title="Show item info">
|
||||||
|
<InformationCircleIcon />
|
||||||
|
</RouterLink>
|
||||||
|
<ClipboardButton v-if="!hideCopy" :value="computedName" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
button:deep(>svg) {
|
@reference "@/style.css";
|
||||||
@apply relative top-0.5 !w-4 !h-4;
|
|
||||||
|
button:deep(>svg), .btn-icon:deep(>svg) {
|
||||||
|
@apply !w-4 !h-4;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import {mammonAddCharacterUrl} from "@/mammon";
|
||||||
|
import {CharacterLabel, useCharactersStore} from "@/characters";
|
||||||
|
import {ArrowPathIcon} from '@heroicons/vue/24/outline';
|
||||||
|
|
||||||
|
const charactersStore = useCharactersStore()
|
||||||
|
|
||||||
|
const addCharacter = () => {
|
||||||
|
window.location.replace(mammonAddCharacterUrl);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid mb-2 mt-4">
|
||||||
|
<div class="mb-4 border-b-1 flex justify-end">
|
||||||
|
<button class="mb-2" @click="addCharacter">Add chacarcter</button>
|
||||||
|
</div>
|
||||||
|
<div v-for="character in charactersStore.characters" :key="character.characterId" class="flex items-center mb-2">
|
||||||
|
<CharacterLabel class="grow" :character="character" />
|
||||||
|
<button class="btn-icon" @click="charactersStore.reloadActivities(character.characterId)"><ArrowPathIcon /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {RouterView} from 'vue-router';
|
||||||
|
import {EditLedgerModal, useLedgersStore} from "@/ledger";
|
||||||
|
import {ref} from "vue";
|
||||||
|
import {activityApi, processingApi} from "@/mammon";
|
||||||
|
|
||||||
|
const ledgersStore = useLedgersStore();
|
||||||
|
|
||||||
|
const editLedgerModal = ref<typeof EditLedgerModal>();
|
||||||
|
|
||||||
|
const processActivities = async () => {
|
||||||
|
await activityApi.fetchAllNewActivities();
|
||||||
|
await processingApi.processNewActivities();
|
||||||
|
await ledgersStore.refresh();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="mb-4 border-b-1 flex justify-end">
|
||||||
|
<button class="mb-2 ms-2" @click="processActivities">Process Activities</button>
|
||||||
|
<button class="mb-2 ms-2" @click="editLedgerModal?.open()">New Ledger</button>
|
||||||
|
</div>
|
||||||
|
<EditLedgerModal ref="editLedgerModal" />
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { usePocketBase } from '@/pocketbase';
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
|
|
||||||
const pb = usePocketBase();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const username = ref("");
|
|
||||||
const password = ref("");
|
|
||||||
|
|
||||||
const login = async () => {
|
|
||||||
await pb.collection('users').authWithPassword(username.value, password.value);
|
|
||||||
await router.push('/');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="p-4 mx-auto mt-10 grid justify-center gap-2 w-64">
|
|
||||||
<div class="grid">
|
|
||||||
Login:
|
|
||||||
<input type="text" name="username" v-model="username" @keyup.enter="login" />
|
|
||||||
</div>
|
|
||||||
<div class="grid">
|
|
||||||
Password:
|
|
||||||
<input type="password" name="password" v-model="password" @keyup.enter="login" />
|
|
||||||
</div>
|
|
||||||
<button class="justify-self-end" name="login" @click="login">Login</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
+4
-13
@@ -1,30 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {RouterLink, RouterView} from 'vue-router';
|
import {RouterLink, RouterView} from 'vue-router';
|
||||||
|
import {routeNames} from '@/routes';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<div class="flex border-b-2 border-emerald-500">
|
<div class="flex border-b-2 border-emerald-500">
|
||||||
<RouterLink :to="{name: 'market-type'}" class="tab">
|
<RouterLink :to="{name: routeNames.marketTypes}" class="tab">
|
||||||
<span>Item Info</span>
|
<span>Item Info</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink to="/market/scan" class="tab">
|
<RouterLink to="/market/scan" class="tab">
|
||||||
<span>Scan</span>
|
<span>Scan</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink to="/market/track" class="tab">
|
<RouterLink to="/market/acquisitions" class="tab">
|
||||||
<span>Tracking</span>
|
<span>Acquisitions</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
|
||||||
a.tab {
|
|
||||||
@apply flex items-center px-4 me-2 rounded-t-md bg-slate-600 hover:bg-slate-700;
|
|
||||||
&.router-link-active {
|
|
||||||
@apply bg-emerald-500 hover:bg-emerald-700;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {RouterLink, RouterView} from "vue-router";
|
||||||
|
import {routeNames} from '@/routes';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="flex border-b-2 border-emerald-500">
|
||||||
|
<RouterLink :to="{ name: routeNames.listRuleBooks }" class="tab">
|
||||||
|
<span>Rule Books</span>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/characters/rules" class="tab">
|
||||||
|
<span>Characters Rules</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import {findAllTransactionInLeger, useLedgerParam} from "@/ledger";
|
||||||
|
import {computedAsync} from "@vueuse/core";
|
||||||
|
import {TransactionResponse} from "@/generated/mammon";
|
||||||
|
import {IskLabel} from "@/market";
|
||||||
|
import {SortableHeader, useSort, VirtualScrollTable} from "@/components/table";
|
||||||
|
import {TransferList, TransferTypes} from "@/transaction";
|
||||||
|
import {Dropdown} from "@/components";
|
||||||
|
import {CharacterLabel, useCharactersStore} from "@/characters";
|
||||||
|
import {formatEveDate} from "@/formaters.ts";
|
||||||
|
|
||||||
|
const {ledgerId} = useLedgerParam();
|
||||||
|
const charactersStore = useCharactersStore();
|
||||||
|
|
||||||
|
const transactions = computedAsync<TransactionResponse[]>(async () => {
|
||||||
|
if (ledgerId.value) {
|
||||||
|
return await findAllTransactionInLeger(ledgerId.value);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { sortedArray, headerProps } = useSort(computedAsync(() => Promise.all(transactions.value.map(async transaction => {
|
||||||
|
const character = await charactersStore.findById(transaction.characterId);
|
||||||
|
return {
|
||||||
|
character,
|
||||||
|
characterName: character?.name ?? "",
|
||||||
|
transactionId: transaction.transactionId,
|
||||||
|
description: transaction.description,
|
||||||
|
date: new Date(transaction.datetime),
|
||||||
|
balance: getIskBalance(transaction),
|
||||||
|
transfers: transaction.transfers
|
||||||
|
}
|
||||||
|
})), []), { defaultSortKey: 'date', defaultSortDirection: 'desc' });
|
||||||
|
|
||||||
|
const getIskBalance = (transaction: TransactionResponse) => {
|
||||||
|
if (!ledgerId.value) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let balance = 0;
|
||||||
|
|
||||||
|
for (const transfer of transaction.transfers) {
|
||||||
|
if (transfer.type === TransferTypes.Isk) {
|
||||||
|
if (transfer.toLedgerId === ledgerId.value) {
|
||||||
|
balance += transfer.amount;
|
||||||
|
} else if (transfer.fromLedgerId === ledgerId.value) {
|
||||||
|
balance -= transfer.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return balance;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-4">
|
||||||
|
<VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem">
|
||||||
|
<template #default="{ list }">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="characterName">Character</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="date">Date</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="balance">Isk Change</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="description">Description</SortableHeader>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="t in list" :key="t.data.transactionId">
|
||||||
|
<td>
|
||||||
|
<CharacterLabel v-if="t.data.character" :character="t.data.character" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Dropdown class="transfer-dropdown">
|
||||||
|
<template #button>
|
||||||
|
{{formatEveDate(t.data.date)}}
|
||||||
|
</template>
|
||||||
|
<TransferList :transfers="t.data.transfers" />
|
||||||
|
</Dropdown>
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<IskLabel :amount="t.data.balance" />
|
||||||
|
</td>
|
||||||
|
<td>{{t.data.description}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</template>
|
||||||
|
</VirtualScrollTable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
|
||||||
|
tr:hover>td>.transfer-dropdown :deep(>button) {
|
||||||
|
@apply bg-slate-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-dropdown :deep(>button) {
|
||||||
|
@apply bg-slate-800 hover:bg-slate-900 border-none flex items-center w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-dropdown.dropdown-open :deep(>button) {
|
||||||
|
@apply bg-slate-800 rounded-b-none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import {EditLedgerModal, Ledger, LedgerLabel, useLedgersStore} from "@/ledger";
|
||||||
|
import {nextTick, ref} from "vue";
|
||||||
|
import {PencilSquareIcon} from "@heroicons/vue/24/outline";
|
||||||
|
import {IskLabel} from "@/market";
|
||||||
|
import {SortableHeader, useSort, VirtualScrollTable} from "@/components/table";
|
||||||
|
|
||||||
|
const ledgersStore = useLedgersStore();
|
||||||
|
|
||||||
|
const { sortedArray, headerProps } = useSort<Ledger>(() => ledgersStore.ledgers);
|
||||||
|
|
||||||
|
const editModal = ref<typeof EditLedgerModal>();
|
||||||
|
const editingLedgerId = ref("");
|
||||||
|
|
||||||
|
const openEdit = async (ledgerId: string) => {
|
||||||
|
editingLedgerId.value = ledgerId;
|
||||||
|
await nextTick();
|
||||||
|
editModal.value?.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-4">
|
||||||
|
<VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem">
|
||||||
|
<template #default="{ list }">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="name">Ledger</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="balance">Isk Balance</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="buttons" unsortable />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="l in list" :key="l.data.ledgerId">
|
||||||
|
<td>
|
||||||
|
<LedgerLabel :ledger="l.data" :link="true" />
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<IskLabel class="ms-2" :amount="l.data.balance" />
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<button class="btn-icon ms-2" @click="openEdit(l.data.ledgerId)"><PencilSquareIcon /></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</template>
|
||||||
|
<template #empty>
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<span>No ledgers found</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VirtualScrollTable>
|
||||||
|
</div>
|
||||||
|
<EditLedgerModal ref="editModal" :ledger-id="editingLedgerId" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {RouterLink, RouterView} from 'vue-router';
|
||||||
|
import {isMain, useLedgerParam} from "@/ledger";
|
||||||
|
import {routeNames} from "@/routes.ts";
|
||||||
|
|
||||||
|
const {ledgerId, ledger} = useLedgerParam();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="ledger" class="mt-4">
|
||||||
|
<div class="flex border-b-2 border-emerald-500">
|
||||||
|
<RouterLink :to="{name: routeNames.viewLedgerBalance}" class="tab">
|
||||||
|
<span>Balance</span>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink v-if="isMain(ledger)" :to="{name: routeNames.listLedgerTransactions}" class="tab">
|
||||||
|
<span>Transactions</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {getLedgerBalance, useLedgerParam} from "@/ledger";
|
||||||
|
import {computedAsync} from "@vueuse/core";
|
||||||
|
import {BalanceResponse} from "@/generated/mammon";
|
||||||
|
import {getMarketType, IskLabel, MarketTypeLabel} from "@/market";
|
||||||
|
import {SortableHeader, useSort, VirtualScrollTable} from "@/components/table";
|
||||||
|
|
||||||
|
const {ledgerId} = useLedgerParam();
|
||||||
|
|
||||||
|
const balance = computedAsync<BalanceResponse>(async () => {
|
||||||
|
if (ledgerId.value) {
|
||||||
|
return await getLedgerBalance(ledgerId.value);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { sortedArray, headerProps } = useSort(computedAsync(async () => {
|
||||||
|
const itemBalances = balance.value?.itemBalances;
|
||||||
|
|
||||||
|
if (!itemBalances) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await Promise.all(itemBalances.map(async i => {
|
||||||
|
const item = await getMarketType(i.typeId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...i,
|
||||||
|
name: item.name
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
}, []));
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="balance" class="mt-4">
|
||||||
|
<div class="flex justify-end w-full">
|
||||||
|
<IskLabel class="mb-2" :amount="balance.iskBalance" />
|
||||||
|
</div>
|
||||||
|
<VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem">
|
||||||
|
<template #default="{ list }">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="quantity">Balance</SortableHeader>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="i in list" :key="i.data.typeId">
|
||||||
|
<td>
|
||||||
|
<MarketTypeLabel :id="i.data.typeId" :name="i.data.name" />
|
||||||
|
</td>
|
||||||
|
<td class="text-right">{{i.data.quantity}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</template>
|
||||||
|
</VirtualScrollTable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {getMarketTypes, MarketTypePrice, useApraisalStore} from "@/market";
|
||||||
|
import {AcquiredType, AcquisitionResultTable, BuyModal, SellModal, useAcquiredTypesStore} from '@/market/acquisition';
|
||||||
|
import {ref, watch} from 'vue';
|
||||||
|
import {activityApi, processingApi} from "@/mammon";
|
||||||
|
|
||||||
|
const buyModal = ref<typeof BuyModal>();
|
||||||
|
const sellModal = ref<typeof SellModal>();
|
||||||
|
|
||||||
|
const apraisalStore = useApraisalStore();
|
||||||
|
const acquiredTypesStore = useAcquiredTypesStore();
|
||||||
|
const items = ref<AcquiredType[]>([]);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
await activityApi.fetchAllNewActivities();
|
||||||
|
await processingApi.processNewActivities();
|
||||||
|
await acquiredTypesStore.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => acquiredTypesStore.acquiredTypes, async itms => {
|
||||||
|
if (itms.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prices = await apraisalStore.getPrices(await getMarketTypes([...new Set(itms.map(i => i.type))]));
|
||||||
|
|
||||||
|
items.value = itms.map(i => {
|
||||||
|
const price = prices.find(p => p.type.id === i.type) as MarketTypePrice;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...i,
|
||||||
|
type: price.type,
|
||||||
|
buy: price.buy,
|
||||||
|
sell: price.sell
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="flex">
|
||||||
|
<button class="ms-auto" @click="refresh">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<template v-if="items.length > 0">
|
||||||
|
<AcquisitionResultTable :items="items" @buy="(types, price, buy, sell) => buyModal?.open(types[0].type, { 'Price': price, 'Buy': buy, 'Sell': sell })" @sell="types => sellModal?.open(types)" ignoredColums="date" />
|
||||||
|
<BuyModal ref="buyModal" />
|
||||||
|
<SellModal ref="sellModal" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+45
-57
@@ -1,76 +1,64 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { MarketType, MarketTypeInput, MarketTypePrice, getHistory, getMarketTypes, jitaId, useApraisalStore } from "@/market";
|
import {getMarketTypes, TaxInput, useMarketTaxStore} from "@/market";
|
||||||
import { ScanResult, ScanResultTable, createResult, useMarketScanStore } from '@/market/scan';
|
import {BuyModal} from '@/market/acquisition';
|
||||||
import { BuyModal } from '@/market/track';
|
import {ScanResult, ScanResultTable, toScanResult} from '@/market/scan';
|
||||||
|
import {marketApi} from "@/mammon";
|
||||||
|
import {useStorage} from "@vueuse/core";
|
||||||
import {ref, watch} from 'vue';
|
import {ref, watch} from 'vue';
|
||||||
|
|
||||||
|
|
||||||
const buyModal = ref<typeof BuyModal>();
|
const buyModal = ref<typeof BuyModal>();
|
||||||
|
|
||||||
const item = ref<MarketType>();
|
const marketTaxStore = useMarketTaxStore();
|
||||||
|
const days = useStorage('market-scan-days', 365);
|
||||||
const apraisalStore = useApraisalStore();
|
|
||||||
const markeyScanStore = useMarketScanStore();
|
|
||||||
const items = ref<ScanResult[]>([]);
|
const items = ref<ScanResult[]>([]);
|
||||||
const addOrRelaod = async (type: MarketType) => {
|
const loading = ref(false);
|
||||||
const typeID = type.id;
|
|
||||||
const [history, price] = await Promise.all([
|
|
||||||
getHistory(jitaId, typeID),
|
|
||||||
apraisalStore.getPrice(type)
|
|
||||||
]);
|
|
||||||
const itm = {
|
|
||||||
type,
|
|
||||||
history,
|
|
||||||
buy: price.buy,
|
|
||||||
sell: price.sell,
|
|
||||||
orderCount: price.orderCount
|
|
||||||
};
|
|
||||||
|
|
||||||
if (items.value.some(i => i.type.id === typeID)) {
|
const scan = async () => {
|
||||||
items.value = items.value.map(i => i.type.id === typeID ? itm : i);
|
loading.value = true;
|
||||||
} else {
|
try {
|
||||||
items.value = [ ...items.value, itm];
|
const { data } = await marketApi.scanMarket(
|
||||||
|
days.value,
|
||||||
|
marketTaxStore.brokerFee / 100,
|
||||||
|
marketTaxStore.scc / 100
|
||||||
|
);
|
||||||
|
const types = await getMarketTypes(data.map(r => r.marketTypeId));
|
||||||
|
|
||||||
|
items.value = data.flatMap(r => {
|
||||||
|
const type = types.find(t => t.id === r.marketTypeId);
|
||||||
|
|
||||||
|
return type ? [toScanResult(r, type)] : [];
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const addItem = async () => {
|
|
||||||
if (!item.value) {
|
|
||||||
// TODO error
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
addOrRelaod(item.value);
|
watch([days, () => marketTaxStore.brokerFee, () => marketTaxStore.scc], scan, { immediate: true });
|
||||||
item.value = undefined;
|
|
||||||
}
|
|
||||||
const removeItem = (type: MarketType) => {
|
|
||||||
items.value = items.value.filter(i => i.type.id !== type.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
watch(items, async itms => markeyScanStore.setTypes(itms.map(i => i.type.id)));
|
|
||||||
watch(() => markeyScanStore.types, async t => {
|
|
||||||
const typesToLoad = t.filter(t => !items.value.some(i => i.type.id === t));
|
|
||||||
|
|
||||||
if (typesToLoad.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prices = await apraisalStore.getPrices(await getMarketTypes(typesToLoad));
|
|
||||||
|
|
||||||
items.value = [...items.value, ...(await Promise.all(typesToLoad.map(i => createResult(i, prices.find(p => p.type.id === i) as MarketTypePrice))))];
|
|
||||||
}, { immediate: true });
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="grid mb-2 mt-4">
|
<div class="flex mb-2 mt-4">
|
||||||
<div class="w-auto flex">
|
<div class="flex justify-self-end ms-auto">
|
||||||
<span>Item: </span>
|
<TaxInput />
|
||||||
<MarketTypeInput class="ms-2" v-model="item" @submit="addItem"/>
|
<div class="end">
|
||||||
<button class="justify-self-end ms-2" @click="addItem">Add</button>
|
<span>Days: </span>
|
||||||
|
<input type="number" min="1" max="365" step="1" v-model="days" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="items.length > 0">
|
|
||||||
<hr />
|
<hr />
|
||||||
<ScanResultTable :items="items" @buy="(type, buy, sell) => buyModal?.open(type, { 'Buy': buy, 'Sell': sell })" @remove="removeItem" />
|
<div v-if="loading" class="text-center mt-4">
|
||||||
|
<span>Scanning market…</span>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<ScanResultTable :items="items" @buy="(type, buy, sell) => buyModal?.open(type, { 'Buy': buy, 'Sell': sell })" />
|
||||||
<BuyModal ref="buyModal" />
|
<BuyModal ref="buyModal" />
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
div.end {
|
||||||
|
@apply justify-self-end ms-2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { MarketTypePrice, getMarketTypes, useApraisalStore } from "@/market";
|
|
||||||
import { BuyModal, SellModal, TrackResultTable, TrackedItem, useTrackedItemStore } from '@/market/track';
|
|
||||||
import { ref, watch } from 'vue';
|
|
||||||
|
|
||||||
const buyModal = ref<typeof BuyModal>();
|
|
||||||
const sellModal = ref<typeof SellModal>();
|
|
||||||
|
|
||||||
|
|
||||||
const apraisalStore = useApraisalStore();
|
|
||||||
const trackedItemStore = useTrackedItemStore();
|
|
||||||
const items = ref<TrackedItem[]>([]);
|
|
||||||
|
|
||||||
watch(() => trackedItemStore.items.value, async itms => {
|
|
||||||
if (itms.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prices = await apraisalStore.getPrices(await getMarketTypes(itms.map(i => i.typeID)));
|
|
||||||
|
|
||||||
items.value = itms.map(i => {
|
|
||||||
const price = prices.find(p => p.type.id === i.typeID) as MarketTypePrice;
|
|
||||||
|
|
||||||
return { ...i, ...price };
|
|
||||||
});
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="mt-4">
|
|
||||||
<template v-if="items.length > 0">
|
|
||||||
<TrackResultTable :items="items" @buy="(type, price, buy, sell) => buyModal?.open(type, { 'Price': price, 'Buy': buy, 'Sell': sell })" @sell="type => sellModal?.open(type)" />
|
|
||||||
<BuyModal ref="buyModal" />
|
|
||||||
<SellModal ref="sellModal" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ClipboardButton} from '@/components';
|
import {ClipboardButton} from '@/components';
|
||||||
import { MarketType, MarketTypeInput, getMarketType, useApraisalStore } from "@/market";
|
import {getMarketType, MarketType, MarketTypeInput, useApraisalStore, useMarketTaxStore} from "@/market";
|
||||||
import { ScanResultTable, createResult, useMarketScanStore } from '@/market/scan';
|
import {AcquisitionResultTable, BuyModal, useAcquiredTypesStore} from '@/market/acquisition';
|
||||||
import { BuyModal } from '@/market/track';
|
import {ScanResultTable, toScanResult} from '@/market/scan';
|
||||||
import { BookmarkIcon, BookmarkSlashIcon, ShoppingCartIcon } from '@heroicons/vue/24/outline';
|
import {marketApi} from "@/mammon";
|
||||||
import { computedAsync } from '@vueuse/core/index.cjs';
|
import {ShoppingCartIcon} from '@heroicons/vue/24/outline';
|
||||||
import log from "loglevel";
|
import log from "loglevel";
|
||||||
import {computed, ref, watch} from "vue";
|
import {computed, ref, watch} from "vue";
|
||||||
import {useRoute, useRouter} from "vue-router";
|
import {useRoute, useRouter} from "vue-router";
|
||||||
|
import {routeNames} from "@/routes";
|
||||||
|
import {computedAsync, useStorage} from "@vueuse/core";
|
||||||
|
|
||||||
const buyModal = ref<typeof BuyModal>();
|
const buyModal = ref<typeof BuyModal>();
|
||||||
|
|
||||||
@@ -17,29 +19,44 @@ const item = ref<MarketType>();
|
|||||||
const inputItem = ref<MarketType>();
|
const inputItem = ref<MarketType>();
|
||||||
|
|
||||||
const apraisalStore = useApraisalStore();
|
const apraisalStore = useApraisalStore();
|
||||||
|
const marketTaxStore = useMarketTaxStore();
|
||||||
|
const days = useStorage('market-scan-days', 365);
|
||||||
const price = computedAsync(() => item.value ? apraisalStore.getPrice(item.value) : undefined);
|
const price = computedAsync(() => item.value ? apraisalStore.getPrice(item.value) : undefined);
|
||||||
const markeyScanStore = useMarketScanStore();
|
const result = computedAsync(async () => {
|
||||||
const result = computedAsync(async () => item.value && price.value ? await createResult(item.value?.id, price.value) : undefined);
|
|
||||||
|
|
||||||
const isTracked = computed(() => item.value ? markeyScanStore.types.includes(item.value.id) : false);
|
|
||||||
const toogleTracking = () => {
|
|
||||||
if (!item.value) {
|
if (!item.value) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
|
||||||
if (isTracked.value) {
|
|
||||||
markeyScanStore.removeType(item.value.id);
|
|
||||||
} else {
|
|
||||||
markeyScanStore.addType(item.value.id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { data } = await marketApi.scanMarketType(
|
||||||
|
item.value.id,
|
||||||
|
days.value,
|
||||||
|
marketTaxStore.brokerFee / 100,
|
||||||
|
marketTaxStore.scc / 100
|
||||||
|
);
|
||||||
|
|
||||||
|
return toScanResult(data, item.value);
|
||||||
|
});
|
||||||
|
const acquiredTypesStore = useAcquiredTypesStore();
|
||||||
|
|
||||||
|
const acquisitions = computed(() => {
|
||||||
|
const p = price.value;
|
||||||
|
|
||||||
|
return !p ?[] : acquiredTypesStore.acquiredTypes
|
||||||
|
.filter(t => t.type === item.value?.id)
|
||||||
|
.map(i => ({
|
||||||
|
...i,
|
||||||
|
type: p.type,
|
||||||
|
buy: p.buy,
|
||||||
|
sell: p.sell
|
||||||
|
}));
|
||||||
|
});
|
||||||
const view = () => {
|
const view = () => {
|
||||||
if (!inputItem.value) {
|
if (!inputItem.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push({
|
router.push({
|
||||||
name: 'market-type',
|
name: routeNames.marketTypes,
|
||||||
params: {
|
params: {
|
||||||
type: inputItem.value.id
|
type: inputItem.value.id
|
||||||
}
|
}
|
||||||
@@ -72,28 +89,33 @@ watch(useRoute(), async route => {
|
|||||||
<template v-if="item">
|
<template v-if="item">
|
||||||
<hr>
|
<hr>
|
||||||
<div class="p-2 mb-4 flex">
|
<div class="p-2 mb-4 flex">
|
||||||
<img v-if="item.id" :src="`https://images.evetech.net/types/${item.id}/icon?size=64`" class="type-image inline-block me-2" />
|
<img v-if="item.id" :src="`https://images.evetech.net/types/${item.id}/icon?size=64`" class="type-image inline-block me-2" alt="" />
|
||||||
<div class="inline-block align-top w-full">
|
<div class="inline-block align-top w-full">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<span class="text-lg font-semibold">{{ item.name }}</span>
|
<span class="text-lg font-semibold">{{ item.name }}</span>
|
||||||
<div class="ms-auto">
|
<div class="ms-auto">
|
||||||
<ClipboardButton class="ms-1" :value="item.name" />
|
<ClipboardButton class="ms-1" :value="item.name" />
|
||||||
<button v-if="price" class="btn-icon ms-1" @click="buyModal?.open(item, { 'Buy': price.buy, 'Sell': price.sell })"><ShoppingCartIcon /></button>
|
<button v-if="price" class="btn-icon ms-1" title="Add acquisitions" @click="buyModal?.open(item, { 'Buy': price.buy, 'Sell': price.sell })"><ShoppingCartIcon /></button>
|
||||||
<button class="btn-icon ms-1" :title="isTracked ? 'Untrack' : 'Track'" @click="toogleTracking">
|
|
||||||
<BookmarkSlashIcon v-if="isTracked" />
|
|
||||||
<BookmarkIcon v-else />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="item.description" class="text-sm">{{ item.description }}</p>
|
<p v-if="item.description" class="text-sm">{{ item.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ScanResultTable v-if="result" :items="[result]" infoOnly />
|
<div v-if="result" class="mb-4">
|
||||||
|
<span>Market Info:</span>
|
||||||
|
<ScanResultTable :items="[result]" infoOnly :ignoredColums="['name', 'acquisitions']" />
|
||||||
|
</div>
|
||||||
|
<div v-if="acquisitions && acquisitions.length > 0">
|
||||||
|
<span>Acquisitions:</span>
|
||||||
|
<AcquisitionResultTable :items="acquisitions" infoOnly showAll :ignoredColums="['name', 'buy', 'sell']" defaultSortKey="date"/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<BuyModal ref="buyModal" />
|
<BuyModal ref="buyModal" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
|
||||||
img.type-image {
|
img.type-image {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {Character, CharacterLabel, useCharactersStore} from "@/characters";
|
||||||
|
import {useRoute} from "vue-router";
|
||||||
|
import {computed, ref, watch, watchEffect} from "vue";
|
||||||
|
import log from "loglevel";
|
||||||
|
import {
|
||||||
|
RuleBook,
|
||||||
|
useCharacterRuleBooksStore,
|
||||||
|
useRuleBooksStore
|
||||||
|
} from "@/rules";
|
||||||
|
import {isMain, Ledger, LedgerSelect, systemLedger, useLedgersStore} from "@/ledger";
|
||||||
|
|
||||||
|
type Bindings = { [key: string]: Ledger; };
|
||||||
|
|
||||||
|
const ruleBookStore = useRuleBooksStore();
|
||||||
|
const characterRuleBooksStore = useCharacterRuleBooksStore();
|
||||||
|
const charactersStore = useCharactersStore();
|
||||||
|
const ledgersStore = useLedgersStore();
|
||||||
|
|
||||||
|
const ledgersToUse = computed(() => [systemLedger, ...ledgersStore.ledgers.filter(isMain)]);
|
||||||
|
|
||||||
|
const character = ref<Character>();
|
||||||
|
const ruleBook = ref<RuleBook>();
|
||||||
|
const bindings = ref<Bindings>({});
|
||||||
|
const ledgerRefs = computed<string[]>(() => ruleBook.value?.ledgerRefs ?? [])
|
||||||
|
|
||||||
|
watchEffect(async () => {
|
||||||
|
const characterId = character.value?.characterId;
|
||||||
|
|
||||||
|
if (characterId) {
|
||||||
|
const characterRuleBook = characterRuleBooksStore.findByCharacterId(characterId);
|
||||||
|
|
||||||
|
ruleBook.value = ruleBookStore.findById(characterRuleBook?.ruleBook.ruleBookId ?? '');
|
||||||
|
bindings.value = Object.fromEntries(
|
||||||
|
Object.entries(characterRuleBook?.bindings ?? {})
|
||||||
|
.map(([key, id]) => [key, ledgersToUse.value.find(l => l.ledgerId === id) ?? systemLedger])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
const characterId = character.value?.characterId;
|
||||||
|
const ruleBookId = ruleBook.value?.ruleBookId;
|
||||||
|
|
||||||
|
if (characterId && ruleBookId) {
|
||||||
|
characterRuleBooksStore.setForCharacter(characterId, {
|
||||||
|
ruleBookId,
|
||||||
|
bindings: Object.fromEntries(
|
||||||
|
Object.entries(bindings.value)
|
||||||
|
.map(([key, ledger]) => [key, ledger.ledgerId])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(useRoute(), async route => {
|
||||||
|
if (route.params.characterId) {
|
||||||
|
const id = parseInt(typeof route.params.characterId === 'string' ? route.params.characterId : route.params.characterId[0]);
|
||||||
|
|
||||||
|
character.value = await charactersStore.findById(id);
|
||||||
|
log.info('Loaded character:', character.value);
|
||||||
|
} else {
|
||||||
|
character.value = undefined;
|
||||||
|
log.info('No character to load');
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="character" class="grid mb-2 mt-4">
|
||||||
|
<div class="mb-2 border-b-1 flex">
|
||||||
|
<CharacterLabel class="flex grow mb-2" :character="character" :size="64" />
|
||||||
|
<div>
|
||||||
|
<button @click="save">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-col border-b-1">
|
||||||
|
Rule Book:
|
||||||
|
<select class="me-2 mb-2 w-50" v-model="ruleBook">
|
||||||
|
<option v-for="rb in ruleBookStore.ruleBooks" :key="rb.ruleBookId" :value="rb">{{ rb.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex-col border-b-1">
|
||||||
|
Ledger Bindings:
|
||||||
|
<div class="flex flex-wrap items-center mb-2 mt-2">
|
||||||
|
<div class="me-2" v-for="ref in ledgerRefs" :ref="ref">
|
||||||
|
<span class="me-1">{{ref}}:</span>
|
||||||
|
<LedgerSelect :ledgers="ledgersToUse" :modelValue="bindings[ref] ?? systemLedger" @update:modelValue="value => { if (value) bindings[ref] = value }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {useRoute, useRouter} from "vue-router";
|
||||||
|
import {ref, watch} from "vue";
|
||||||
|
import {useDebounceFn, useEventListener} from "@vueuse/core";
|
||||||
|
import log from "loglevel";
|
||||||
|
import {ScriptEditor, useRuleBooksStore} from "@/rules";
|
||||||
|
import {PlusIcon, TrashIcon} from "@heroicons/vue/24/outline";
|
||||||
|
import {routeNames} from "@/routes";
|
||||||
|
import {SliderCheckbox} from "@/components";
|
||||||
|
|
||||||
|
const ruleBookId = ref<string>();
|
||||||
|
const name = ref<string>('');
|
||||||
|
const usedForAcquisitions = ref<boolean>(false);
|
||||||
|
const ledgerRefs = ref<string[]>([]);
|
||||||
|
const script = ref<string>('');
|
||||||
|
|
||||||
|
const ruleBooksStore = useRuleBooksStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!ruleBookId.value) {
|
||||||
|
const created = await ruleBooksStore.create({
|
||||||
|
name: name.value,
|
||||||
|
usedForAcquisitions: usedForAcquisitions.value,
|
||||||
|
ledgerRefs: ledgerRefs.value,
|
||||||
|
script: script.value
|
||||||
|
})
|
||||||
|
await router.push({ name: routeNames.editRuleBook, params: {ruleBookId: created.ruleBookId}})
|
||||||
|
|
||||||
|
} else {
|
||||||
|
await ruleBooksStore.update(ruleBookId.value, {
|
||||||
|
name: name.value,
|
||||||
|
usedForAcquisitions: usedForAcquisitions.value,
|
||||||
|
ledgerRefs: ledgerRefs.value,
|
||||||
|
script: script.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
|
||||||
|
event.preventDefault();
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
}, {capture: true});
|
||||||
|
|
||||||
|
const addLedgerRef = () => {
|
||||||
|
ledgerRefs.value = [...ledgerRefs.value, '']
|
||||||
|
}
|
||||||
|
const updateLedgerRef = useDebounceFn((index: number, value: string) => {
|
||||||
|
ledgerRefs.value[index] = value;
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
const removeLedgerRef = (index: number) => {
|
||||||
|
ledgerRefs.value = ledgerRefs.value.toSpliced(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(useRoute(), async route => {
|
||||||
|
if (route.params.ruleBookId) {
|
||||||
|
const promise = ruleBooksStore.refresh(); // FIXME don't call refresh
|
||||||
|
|
||||||
|
const id = typeof route.params.ruleBookId === 'string' ? route.params.ruleBookId : route.params.ruleBookId[0];
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
const ruleBook = ruleBooksStore.findById(id);
|
||||||
|
|
||||||
|
ruleBookId.value = id;
|
||||||
|
name.value = ruleBook?.name ?? '';
|
||||||
|
usedForAcquisitions.value = ruleBook?.usedForAcquisitions ?? false;
|
||||||
|
ledgerRefs.value = [...(ruleBook?.ledgerRefs ?? [])];
|
||||||
|
script.value = ruleBook?.script ?? '';
|
||||||
|
log.info('Loaded rule book:', ruleBook);
|
||||||
|
} else {
|
||||||
|
ruleBookId.value = undefined;
|
||||||
|
name.value = '';
|
||||||
|
usedForAcquisitions.value = false;
|
||||||
|
ledgerRefs.value = [];
|
||||||
|
script.value = '';
|
||||||
|
log.info('No rule book to load');
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col mb-2 mt-4 h-[calc(100vh-4.5rem)]">
|
||||||
|
<div class="flex flex-col grow min-h-0">
|
||||||
|
<div class="flex grow border-b-1">
|
||||||
|
Name:
|
||||||
|
<input class="mb-2 ms-2" type="text" v-model="name" />
|
||||||
|
<label class="flex items-center ms-2 mb-2">
|
||||||
|
<SliderCheckbox class="me-2" v-model="usedForAcquisitions" />
|
||||||
|
Used for acquisitions
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="border-b-1">
|
||||||
|
Ledgers References:
|
||||||
|
<div class="flex flex-wrap items-center mt-2">
|
||||||
|
<div class="flex items-center mb-2" v-for="(ledgerRef, index) in ledgerRefs" :key="index">
|
||||||
|
<input class="me-1" type="text" :value="ledgerRef" @input="updateLedgerRef(index, ($event.target as HTMLInputElement).value)" />
|
||||||
|
<button class="btn-icon me-2" @click="removeLedgerRef(index)"><TrashIcon /></button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<button class="btn-icon" @click="addLedgerRef"><PlusIcon /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col grow min-h-0 border-b-1">
|
||||||
|
Script:
|
||||||
|
<ScriptEditor class="mt-2 mb-2" v-model="script" :ledgerRefs="ledgerRefs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 justify-end flex">
|
||||||
|
<div>
|
||||||
|
<button @click="save">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {Character, CharacterLabel} from "@/characters";
|
||||||
|
import {PencilSquareIcon} from "@heroicons/vue/24/outline";
|
||||||
|
import {CharacterRuleBook, useCharacterRuleBooksStore} from "@/rules";
|
||||||
|
import {routeNames} from "@/routes.ts";
|
||||||
|
import {SortableHeader, useSort} from "@/components/table";
|
||||||
|
|
||||||
|
type CharacterRuleBookView = {
|
||||||
|
character: Character;
|
||||||
|
characterName: string;
|
||||||
|
ruleBookName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const characterRuleBooksStore = useCharacterRuleBooksStore();
|
||||||
|
|
||||||
|
const { sortedArray, headerProps } = useSort<CharacterRuleBookView>(() => characterRuleBooksStore.characterRuleBooks.map((characterRuleBook: CharacterRuleBook): CharacterRuleBookView => ({
|
||||||
|
character: characterRuleBook.character,
|
||||||
|
characterName: characterRuleBook.character.name,
|
||||||
|
ruleBookName: characterRuleBook.ruleBook.name
|
||||||
|
})))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid mb-2 mt-4">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="characterName">Character</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="ruleBookName">Rule Book</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="buttons" unsortable />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="characterRuleBookView in sortedArray" :key="characterRuleBookView.character.characterId" >
|
||||||
|
<td>
|
||||||
|
<CharacterLabel :character="characterRuleBookView.character" />
|
||||||
|
</td>
|
||||||
|
<td>{{characterRuleBookView.ruleBookName}}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<RouterLink class="btn-icon" :to="{ name: routeNames.editCharacterRulebook, params: { characterId: characterRuleBookView.character.characterId } }"><PencilSquareIcon /></RouterLink>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {DocumentDuplicateIcon, PencilSquareIcon, TrashIcon} from "@heroicons/vue/24/outline";
|
||||||
|
import {confirm} from "@/confirm";
|
||||||
|
import {RuleBook, useRuleBooksStore} from "@/rules";
|
||||||
|
import {routeNames} from "@/routes";
|
||||||
|
|
||||||
|
const ruleBooksStore = useRuleBooksStore();
|
||||||
|
|
||||||
|
const duplicate = async (ruleBook: RuleBook) => {
|
||||||
|
if (await confirm({title: "Duplicate Rule Book", message: `Duplicate ${ruleBook.name}?`, confirmLabel: "Duplicate"})) {
|
||||||
|
await ruleBooksStore.duplicate(ruleBook);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (ruleBook: RuleBook) => {
|
||||||
|
if (await confirm({title: "Delete Rule Book", message: `Delete ${ruleBook.name}?`, confirmLabel: "Delete", danger: true})) {
|
||||||
|
await ruleBooksStore.remove(ruleBook.ruleBookId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid mb-2 mt-4">
|
||||||
|
<div class="flex justify-end border-b-1">
|
||||||
|
<RouterLink class="button mb-2 ms-2" :to="{ name: routeNames.newRuleBook}">New Rule Book</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div v-for="ruleBook in ruleBooksStore.ruleBooks" :key="ruleBook.ruleBookId" class="flex items-center mt-2">
|
||||||
|
<span class="flex grow me-2">{{ruleBook.name}}</span>
|
||||||
|
<RouterLink class="btn-icon me-1" :to="{ name: routeNames.editRuleBook, params: { ruleBookId: ruleBook.ruleBookId } }"><PencilSquareIcon /></RouterLink>
|
||||||
|
<button class="btn-icon me-1" @click="duplicate(ruleBook)"><DocumentDuplicateIcon /></button>
|
||||||
|
<button class="btn-icon text-amber-700 hover:text-amber-600" @click="remove(ruleBook)"><TrashIcon /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
|
|
||||||
import { usePocketBase } from "@/pocketbase";
|
|
||||||
import { RecordModel, RecordSubscription } from "pocketbase";
|
|
||||||
import { Ref, computed, onMounted, ref } from "vue";
|
|
||||||
|
|
||||||
export const watchCollection = <T extends RecordModel = RecordModel>(collection: string, topic: string, callback: (data: RecordSubscription<T>) => void) => {
|
|
||||||
const pb = usePocketBase();
|
|
||||||
|
|
||||||
onMounted(async () => await pb.collection(collection).subscribe<T>(topic, callback));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useCollection = <T extends RecordModel = RecordModel>(collection: string) => {
|
|
||||||
const pb = usePocketBase();
|
|
||||||
const list = ref<T[]>([]) as Ref<T[]>;
|
|
||||||
|
|
||||||
watchCollection<T>(collection, '*', data => {
|
|
||||||
if (data.action === 'delete') {
|
|
||||||
list.value = list.value.filter(i => i.id !== data.record.id);
|
|
||||||
} else if (data.action === 'update') {
|
|
||||||
list.value = list.value.map(i => i.id === data.record.id ? data.record : i);
|
|
||||||
} else if (data.action === 'create') {
|
|
||||||
list.value = [...list.value, data.record];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
onMounted(async () => list.value = await pb.collection(collection).getFullList<T>().catch(() => [] as T[]));
|
|
||||||
return computed(() => list.value);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './collection';
|
|
||||||
export * from './pocketbase';
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import PocketBase from 'pocketbase';
|
|
||||||
import { App, inject } from 'vue';
|
|
||||||
|
|
||||||
const pocketBaseSymbol = Symbol('pocketBase');
|
|
||||||
|
|
||||||
export const providePocketBase = (app: App) => {
|
|
||||||
const pb = new PocketBase('/pocketbase/');
|
|
||||||
|
|
||||||
app.provide(pocketBaseSymbol, pb);
|
|
||||||
return pb;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const usePocketBase = () => inject<PocketBase>(pocketBaseSymbol)!;
|
|
||||||
@@ -1,31 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useVModel } from '@vueuse/core';
|
|
||||||
|
|
||||||
interface Props {
|
const modelValue = defineModel({ default: false });
|
||||||
modelValue?: boolean;
|
|
||||||
}
|
|
||||||
interface Emits {
|
|
||||||
(e: 'update:modelValue', value: boolean): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
modelValue: false
|
|
||||||
});
|
|
||||||
const emit = defineEmits<Emits>();
|
|
||||||
|
|
||||||
const value = useVModel(props, 'modelValue', emit);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<label class="flex items-center relative w-max cursor-pointer select-none">
|
<label class="flex items-center relative w-max cursor-pointer select-none">
|
||||||
<input type="checkbox" class="appearance-none transition-colors cursor-pointer w-14 h-7 rounded-full" v-model="value" />
|
<input type="checkbox" class="appearance-none transition-colors cursor-pointer w-14 h-7 rounded-full" v-model="modelValue" />
|
||||||
<span class="absolute font-medium text-xs right-1"> Buy </span>
|
<span class="absolute font-medium text-xs right-1"> Buy </span>
|
||||||
<span class="absolute font-medium text-xs right-8"> Sell </span>
|
<span class="absolute font-medium text-xs right-8"> Sell </span>
|
||||||
<span class="w-7 h-7 right-7 absolute rounded-full transform transition-transform bg-slate-100" />
|
<span class="w-7 h-7 right-7 absolute rounded-full transform transition-transform bg-slate-100" />
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
input:checked ~ span:last-child {
|
input:checked ~ span:last-child {
|
||||||
--tw-translate-x: 1.75rem;
|
--tw-translate-x: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { evepraisalAxiosInstance } from '@/market/appraisal/evepraisal';
|
import { evepraisalAxiosInstance } from '@/market/appraisal/evepraisal';
|
||||||
import { useVModel } from '@vueuse/core';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name: string;
|
name: string;
|
||||||
modelValue?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
const modelValue = defineModel({ default: '' });
|
||||||
(e: 'update:modelValue', value: string): void;
|
defineProps<Props>();
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
modelValue: ''
|
|
||||||
});
|
|
||||||
const emit = defineEmits<Emits>();
|
|
||||||
|
|
||||||
const value = useVModel(props, 'modelValue', emit);
|
|
||||||
|
|
||||||
const loadFromId = async (e: Event) => {
|
const loadFromId = async (e: Event) => {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
@@ -31,7 +21,7 @@ const loadFromId = async (e: Event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
value.value = JSON.stringify(response.data);
|
modelValue.value = JSON.stringify(response.data);
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -39,6 +29,6 @@ const loadFromId = async (e: Event) => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex-1 mx-1">
|
<div class="flex-1 mx-1">
|
||||||
<span>{{ name }}</span><input type="text" class="ms-2" @change="loadFromId" placeholder="id evepraisal" />
|
<span>{{ name }}</span><input type="text" class="ms-2" @change="loadFromId" placeholder="id evepraisal" />
|
||||||
<textarea class="mt-1" v-model="value" />
|
<textarea class="mt-1" v-model="modelValue" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { SortableHeader, useSort } from '@/components/table';
|
import { SortableHeader, useSort, VirtualScrollTable } from '@/components/table';
|
||||||
import { formatIsk, percentFormater } from '@/formaters';
|
import { formatIsk, percentFormater } from '@/formaters';
|
||||||
import { MarketTypeLabel } from '@/market/type';
|
import { MarketTypeLabel } from '@/market/type';
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
@@ -46,7 +46,8 @@ const { sortedArray, headerProps } = useSort(computed(() => props.result.map(r =
|
|||||||
<input type="number" min="-100" max="100" step="1" v-model="threshold" />
|
<input type="number" min="-100" max="100" step="1" v-model="threshold" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem">
|
||||||
|
<template #default="{ list }">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
|
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
|
||||||
@@ -56,14 +57,20 @@ const { sortedArray, headerProps } = useSort(computed(() => props.result.map(r =
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="r in sortedArray" :key="r.typeID" :class="{'line-green': r.ratio >= threshold / 100 }">
|
<tr v-for="r in list" :key="r.data.typeID" :class="{'line-green': r.data.ratio >= threshold / 100 }">
|
||||||
<td>
|
<td>
|
||||||
<MarketTypeLabel :id="r.typeID" :name="r.name" />
|
<MarketTypeLabel :id="r.data.typeID" :name="r.data.name" />
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">{{ formatIsk(r.market) }}</td>
|
<td class="text-right">{{ formatIsk(r.data.market) }}</td>
|
||||||
<td class="text-right">{{ formatIsk(r.materials) }}</td>
|
<td class="text-right">{{ formatIsk(r.data.materials) }}</td>
|
||||||
<td class="text-right">{{ percentFormater.format(r.ratio) }}</td>
|
<td class="text-right">{{ percentFormater.format(r.data.ratio) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</template>
|
||||||
|
<template #empty>
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<span>No items found</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VirtualScrollTable>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user