Compare commits
237 Commits
c889a813f3
...
new-eveal
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| a1bffa1cdb | |||
| 98ce81dfb2 | |||
| f115381955 | |||
| 088ea5d929 | |||
| ee6bbfd442 | |||
| 75f70cfd25 | |||
| 1f1821d607 | |||
| 98c818f028 | |||
| 0ea65867a8 | |||
| 2b59f8719a | |||
| 4e4a700ced | |||
| c432450455 | |||
| 5082cfaac9 | |||
| 9dd60ae054 | |||
| ac6c51a714 | |||
| 7d608c19e7 | |||
| a48e49ab9c | |||
| 9bd1ced9d4 | |||
| 4fca2712bf | |||
| c2a09f1c2a | |||
| c2c8f2a65b | |||
| 4cb3de356f | |||
| 0e883dd688 | |||
| 4536d34b92 | |||
| a9cd258af8 | |||
| e78f59b78a | |||
| 7167640e43 | |||
| 866ff0a42b | |||
| b2304916d6 | |||
| 7f83ee2ee2 | |||
| 7d33b77410 | |||
| 66f88ef1b1 | |||
| 2b513a91b0 | |||
| 0026cba23d | |||
| c587fb75f2 | |||
| b9eedf4f07 | |||
| a5e365328c | |||
| 7253b864d9 | |||
| 0ce205a4a0 | |||
| c3205a3e74 | |||
| 575d4dc5ab | |||
| 2c728c7037 | |||
| 14b2f01ef1 | |||
| 1c882e0d1c | |||
| f8e7c95c8b | |||
| 7bd48b5e8d | |||
| 1ac7539dd0 | |||
| eb74ef389e | |||
| dad7bcfbed | |||
| 892fda3f47 | |||
| 2a798744fb | |||
| ac8e41fcce | |||
| 80fdc45174 | |||
| dabadaa1c9 | |||
| d64cb69f1e | |||
| 6a675c28bc | |||
| 7c645b0d0b | |||
| 6587e4f522 | |||
| cd75aa5b13 | |||
| a483580906 | |||
| e8898f76f0 | |||
| 51a37342dd | |||
| 205aef7a3c | |||
| 4a0da46f2c | |||
| 158914048b | |||
| 5cce3e6eca | |||
| 2b80724692 | |||
| 3ac39dcd45 | |||
| 20defc5b0f | |||
| dcdb24c591 | |||
| e499c5aee2 | |||
| 76131aac07 | |||
| adfafb94e4 | |||
| e48fdd3c5c | |||
| 0e1cb94be0 | |||
| ef627d06bc | |||
| 092b7a9763 | |||
| 9dea0b08a6 | |||
| b80d43c375 | |||
| f9eb368fe5 | |||
| cd52e36f70 | |||
| 78c07c7806 | |||
| 6580924bbe | |||
| c1f00da176 | |||
| 3de8f53e0f | |||
| 4301c84b33 | |||
| 145af06874 |
@@ -1,3 +0,0 @@
|
||||
EVEAL_API_URL=/api/
|
||||
EVEPRAISAL_URL=/appraisal/
|
||||
ESI_URL=/esi/
|
||||
@@ -1,3 +0,0 @@
|
||||
EVEAL_API_URL=https://api.eveal.shendai.rip/
|
||||
EVEPRAISAL_URL=https://evepraisal.shendai.rip/
|
||||
ESI_URL=https://esi.evetech.net/latest/
|
||||
@@ -11,6 +11,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
docker-compose.yml
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
@@ -22,3 +23,5 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
generated/mammon/
|
||||
+4
-1
@@ -6,5 +6,8 @@ COPY . ./
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
ENV NGINX_ENVSUBST_OUTPUT_DIR=/usr/share/nginx/html
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
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
@@ -1,4 +1,5 @@
|
||||
server {
|
||||
listen 80 http2;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
|
||||
Generated
+2591
-1730
File diff suppressed because it is too large
Load Diff
+24
-14
@@ -1,29 +1,39 @@
|
||||
{
|
||||
"name": "eveal-frontend",
|
||||
"name": "gemory",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "vite --host --debug",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/vue": "^2.0.18",
|
||||
"@vueuse/core": "^10.2.1",
|
||||
"@vueuse/integrations": "^10.2.1",
|
||||
"@vueuse/components": "^14.3.0",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"@vueuse/integrations": "^14.3.0",
|
||||
"@vueuse/router": "^14.3.0",
|
||||
"axios": "^1.4.0",
|
||||
"gemory": "file:",
|
||||
"loglevel": "^1.8.1",
|
||||
"loglevel-plugin-prefix": "^0.8.4",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"pinia": "^3.0.4",
|
||||
"sortablejs": "^1.15.7",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.4"
|
||||
"vue-router": "^5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.4.5",
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.5",
|
||||
"vue-tsc": "^1.8.5"
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/node": "^25.8.0",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.13",
|
||||
"vite-plugin-runtime-env": "^1.0.0",
|
||||
"vitest": "^4.1.6",
|
||||
"vue-tsc": "^3.2.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
+29
-5
@@ -1,11 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import { Sidebar } from './sidebar';
|
||||
import {computed} from 'vue';
|
||||
import {RouterView, useRoute} from 'vue-router';
|
||||
import {Sidebar} from './sidebar';
|
||||
import {ConfirmModal} from '@/confirm';
|
||||
import {routeNames} from '@/routes';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const hideSidebar = computed(() => {
|
||||
return route.name === routeNames.callback || route.name === routeNames.about;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sidebar />
|
||||
<div class=" px-4 sm:ml-64">
|
||||
<template v-if="hideSidebar">
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Sidebar />
|
||||
<div class="main-container">
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
<ConfirmModal />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/style.css";
|
||||
|
||||
div.main-container {
|
||||
@apply px-4 sm:ml-64;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -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,23 @@
|
||||
<script setup lang="ts">
|
||||
import { copyToClipboard } from '@/utils';
|
||||
import { ClipboardIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const doCopy = () => {
|
||||
if (!props.value) {
|
||||
return;
|
||||
}
|
||||
copyToClipboard(props.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="btn-icon" title="Copy to clipboard" @click="doCopy">
|
||||
<ClipboardIcon />
|
||||
</button>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div role="status">
|
||||
<svg aria-hidden="true" class="inline w-6 h-6 text-slate-500 animate-spin fill-slate-100" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
|
||||
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
|
||||
</svg>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import {vOnClickOutside} from '@vueuse/components';
|
||||
import {useEventListener} from '@vueuse/core';
|
||||
import {watch} from 'vue';
|
||||
|
||||
const open = defineModel('open', { default: false });
|
||||
|
||||
watch(open, value => {
|
||||
if (value) {
|
||||
document.body.classList.add('overflow-hidden');
|
||||
} else {
|
||||
document.body.classList.remove('overflow-hidden');
|
||||
}
|
||||
});
|
||||
useEventListener('keyup', e => {
|
||||
if (e.key === 'Escape') {
|
||||
open.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<template v-if="open">
|
||||
<div class="fixed inset-0 z-10">
|
||||
<div class="absolute bg-black opacity-80 inset-0 z-0" />
|
||||
<div class="absolute grid inset-0">
|
||||
<div class="justify-self-center m-auto" v-on-click-outside="() => open = false">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/style.css";
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
@apply transition-opacity;
|
||||
}
|
||||
</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>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
const modelValue = defineModel({ default: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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="modelValue" />
|
||||
<span class="w-5 h-5 right-5 absolute rounded-full transform transition-transform bg-slate-100 peer-checked:bg-emerald-200" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/style.css";
|
||||
|
||||
input:checked ~ span:last-child {
|
||||
transform: translateX(1.25rem);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
export { default as ClipboardButton } from './ClipboardButton.vue';
|
||||
export { default as Dropdown } from './Dropdown.vue';
|
||||
export { default as LoadingSpinner } from './LoadingSpinner.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 Tooltip } from './tooltip/Tooltip.vue';
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import {HeaderComponent, SortDirection} from './sort';
|
||||
|
||||
interface Props {
|
||||
currentSortKey: string | null;
|
||||
sortDirection?: SortDirection | null;
|
||||
showColumn?: (k: string) => boolean;
|
||||
unsortable?: boolean;
|
||||
sortKey: string;
|
||||
headerComponent?: HeaderComponent;
|
||||
}
|
||||
interface Emit {
|
||||
(e: 'sort', key: string, direction: SortDirection): void;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
showColumn: () => () => true,
|
||||
unsortable: false,
|
||||
headerComponent: 'th',
|
||||
});
|
||||
const emit = defineEmits<Emit>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component v-if="showColumn(sortKey)" :is="headerComponent" class="sort-header">
|
||||
<slot />
|
||||
<template v-if="!unsortable">
|
||||
<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>
|
||||
</template>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/style.css";
|
||||
.sort-header {
|
||||
@apply relative h-8 pe-3;
|
||||
}
|
||||
span.asc, span.desc {
|
||||
@apply absolute end-1 cursor-pointer text-xs transition-opacity;
|
||||
}
|
||||
span.asc {
|
||||
@apply top-0.5;
|
||||
}
|
||||
span.desc {
|
||||
@apply bottom-0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -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 { 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,9 +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 UseSortOptions = {
|
||||
defaultSortKey?: string;
|
||||
defaultSortDirection?: SortDirection;
|
||||
ignoredColums?: MaybeRefOrGetter<string[]>;
|
||||
headerComponent?: HeaderComponent;
|
||||
};
|
||||
|
||||
export const useSort = <T>(array: MaybeRefOrGetter<T[]>, options?: UseSortOptions) => {
|
||||
@@ -13,13 +16,16 @@ export const useSort = <T>(array: MaybeRefOrGetter<T[]>, options?: UseSortOption
|
||||
sortKey.value = key;
|
||||
sortDirection.value = direction;
|
||||
};
|
||||
const showColumn = (sortKey: string) => !toValue(options?.ignoredColums)?.includes(sortKey);
|
||||
const headerProps = computed(() => ({
|
||||
onSort: sortBy,
|
||||
showColumn,
|
||||
currentSortKey: sortKey.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) {
|
||||
return 0;
|
||||
}
|
||||
@@ -38,5 +44,5 @@ export const useSort = <T>(array: MaybeRefOrGetter<T[]>, options?: UseSortOption
|
||||
}
|
||||
}));
|
||||
|
||||
return { sortedArray, headerProps };
|
||||
return { sortedArray, headerProps, showColumn };
|
||||
}
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
+12
-2
@@ -1,10 +1,20 @@
|
||||
const iskFormater = new Intl.NumberFormat("is-IS", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
|
||||
export const formatIsk = (value: number | bigint) => iskFormater.format(value) + " ISK";
|
||||
|
||||
export const percentFormater = new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
minimumFractionDigits: 0
|
||||
});
|
||||
minimumFractionDigits: 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};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import log from "loglevel";
|
||||
import { apply, reg } from "loglevel-plugin-prefix";
|
||||
|
||||
export function initLogger() {
|
||||
log.setLevel(import.meta.env.VITE_LOG_LEVEL);
|
||||
reg(log);
|
||||
apply(log, {template: '[%t] %l:'});
|
||||
}
|
||||
+8
-8
@@ -1,21 +1,21 @@
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp } from 'vue';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import App from './App.vue';
|
||||
import { initLogger } from './logger';
|
||||
import { routes } from './routes';
|
||||
import './style.css';
|
||||
|
||||
const routes = [
|
||||
{ path: '/', component: () => import('@/Index.vue') },
|
||||
{ path: '/reprocess', component: () => import('@/reprocess/Reprocess.vue') },
|
||||
{ path: '/market', component: () => import('@/market/Market.vue') },
|
||||
{ path: '/tools', component: () => import('@/tools/Tools.vue') },
|
||||
];
|
||||
initLogger();
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
@@ -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,71 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowPathIcon } from '@heroicons/vue/24/outline';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { MarketOrderHistory, MarketResult, getHistory, getHistoryQuartils, jitaId } from ".";
|
||||
import MarketReultTable from "./MarketResultTable.vue";
|
||||
import { getMarketType, searchMarketType } from "./type";
|
||||
|
||||
type MarketItem = {
|
||||
typeID: number;
|
||||
history: MarketOrderHistory[];
|
||||
}
|
||||
|
||||
const item = ref("");
|
||||
const items = useStorage<MarketItem[]>('market-items', []);
|
||||
const result = ref<MarketResult[]>([]);
|
||||
const processResult = async (i: MarketItem) => {
|
||||
const type = getMarketType(i.typeID);
|
||||
const quartils = getHistoryQuartils(i.history)
|
||||
|
||||
return {
|
||||
type: await type,
|
||||
q1: quartils.q1,
|
||||
median: quartils.median,
|
||||
q3: quartils.q3
|
||||
};
|
||||
}
|
||||
const addOrRelaod = async (typeID: number) => {
|
||||
const history = await getHistory(jitaId, typeID);
|
||||
const item = { typeID, history };
|
||||
|
||||
if (items.value.some(i => i.typeID === typeID)) {
|
||||
items.value = items.value.map(i => i.typeID === typeID ? item : i);
|
||||
} else {
|
||||
items.value = [ ...items.value, item];
|
||||
}
|
||||
|
||||
const filteredResult = result.value.filter(r => r.type.id !== typeID);
|
||||
|
||||
result.value = [ ...filteredResult, await processResult(item) ];
|
||||
}
|
||||
const reloadAll = async () => {
|
||||
items.value = await Promise.all(items.value.map( async i => ({ ...i, history: await getHistory(jitaId, i.typeID) })));
|
||||
|
||||
result.value = await Promise.all(items.value.map(processResult));
|
||||
}
|
||||
const addItem = async () => {
|
||||
const type = await searchMarketType(item.value.split('\t')[0]);
|
||||
|
||||
item.value = "";
|
||||
addOrRelaod(type.id);
|
||||
}
|
||||
onMounted(async () => {
|
||||
result.value = await Promise.all(items.value.map(processResult));
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-2 mb-2 mt-4">
|
||||
<div class="w-auto">
|
||||
<span>Item: </span>
|
||||
<input type="text" class="w-96" v-model="item" @keyup.enter="addItem" />
|
||||
<button class="justify-self-end ms-2" @click="addItem">Add</button>
|
||||
</div>
|
||||
<button class="justify-self-end flex" @click="reloadAll"><ArrowPathIcon class="h-6 w-6 me-2" />Reload all</button>
|
||||
</div>
|
||||
<template v-if="result.length > 0">
|
||||
<hr />
|
||||
<MarketReultTable :result="result" @relaod="id => addOrRelaod(id)" />
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,65 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { formatIsk, percentFormater } from '@/formaters';
|
||||
import { SortableHeader, useSort } from '@/table';
|
||||
import { copyToClipboard } from '@/utils';
|
||||
import { ArrowPathIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed } from 'vue';
|
||||
import { MarketResult } from ".";
|
||||
import { MarketTypeLabel } from "./type";
|
||||
|
||||
interface Props {
|
||||
result?: MarketResult[];
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'relaod', typeID: number): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
result: () => []
|
||||
});
|
||||
defineEmits<Emits>();
|
||||
|
||||
const { sortedArray, headerProps } = useSort(computed(() => props.result.map(r => ({
|
||||
typeID: r.type.id,
|
||||
name: r.type.name,
|
||||
q1: r.q1,
|
||||
mmedian: r.median,
|
||||
q3: r.q3,
|
||||
percent: r.q3 / r.q1
|
||||
}))), {
|
||||
defaultSortKey: 'name',
|
||||
defaultSortDirection: 'asc'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid mt-2">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
|
||||
<SortableHeader v-bind="headerProps" sortKey="q1">Q1</SortableHeader>
|
||||
<SortableHeader v-bind="headerProps" sortKey="median">Median</SortableHeader>
|
||||
<SortableHeader v-bind="headerProps" sortKey="q3">Q3</SortableHeader>
|
||||
<SortableHeader v-bind="headerProps" sortKey="percent">Percent</SortableHeader>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in sortedArray" :key="r.typeID" class="cursor-pointer" @click="copyToClipboard(r.name)">
|
||||
<td>
|
||||
<MarketTypeLabel :id="r.typeID" :name="r.name" />
|
||||
</td>
|
||||
<td class="text-right">{{ formatIsk(r.q1) }}</td>
|
||||
<td class="text-right">{{ formatIsk(r.mmedian) }}</td>
|
||||
<td class="text-right">{{ formatIsk(r.q3) }}</td>
|
||||
<td class="text-right">{{ percentFormater.format(r.percent) }}</td>
|
||||
<td class="text-right">
|
||||
<button class="btn-icon" @click="$emit('relaod', r.typeID)"><ArrowPathIcon class="hover:stroke-slate-400"/></button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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())
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import {LoadingSpinner, Tooltip} from '@/components';
|
||||
import {formatIsk} from '@/formaters';
|
||||
import {getHistory, getHistoryQuartils} from '@/market';
|
||||
import {ArrowTrendingDownIcon, ArrowTrendingUpIcon} from '@heroicons/vue/24/outline';
|
||||
import {computedAsync} from '@vueuse/core';
|
||||
import {ref, watchEffect} from 'vue';
|
||||
|
||||
const trendingScale = 3;
|
||||
|
||||
interface Props {
|
||||
id: number;
|
||||
buy: number;
|
||||
sell: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
const q1 = ref(0);
|
||||
const median = ref(0);
|
||||
const q3 = ref(0);
|
||||
const lineColor = ref('');
|
||||
const history = computedAsync(() => getHistory(props.id), []);
|
||||
|
||||
watchEffect(async () => {
|
||||
if (!open.value || !props.id) {
|
||||
return;
|
||||
}
|
||||
const quartils = getHistoryQuartils(history.value);
|
||||
|
||||
q1.value = quartils.q1;
|
||||
median.value = quartils.median;
|
||||
q3.value = quartils.q3;
|
||||
|
||||
if (props.buy >= quartils.q3) {
|
||||
lineColor.value = 'line-blue';
|
||||
} else if (props.sell >= quartils.q3) {
|
||||
lineColor.value = 'line-green';
|
||||
} else {
|
||||
lineColor.value = '';
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip v-model:open="open" class="tooltip">
|
||||
<template #header>
|
||||
<LoadingSpinner v-if="history.length < trendingScale" />
|
||||
<ArrowTrendingUpIcon v-else-if="history[0].average > history[trendingScale - 1].average" />
|
||||
<ArrowTrendingDownIcon v-else />
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="bg-slate-500 -left-1/2 relative tooltip-content" v-if="history.length > 0">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Q1</th>
|
||||
<th>Median</th>
|
||||
<th>Q3</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :class="lineColor">
|
||||
<td class="text-right text-nowrap">{{ formatIsk(q1) }}</td>
|
||||
<td class="text-right text-nowrap">{{ formatIsk(median) }}</td>
|
||||
<td class="text-right text-nowrap">{{ formatIsk(q3) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/style.css";
|
||||
|
||||
.tooltip {
|
||||
@apply ms-auto;
|
||||
>:deep(div.header) {
|
||||
@apply btn-icon px-2;
|
||||
}
|
||||
&.open {
|
||||
&.tooltip-top>:deep(div.header) {
|
||||
@apply rounded-t-md bg-slate-600;
|
||||
}
|
||||
&.tooltip-bottom {
|
||||
.tooltip-content {
|
||||
bottom: 79px;
|
||||
}
|
||||
>:deep(div.header) {
|
||||
@apply rounded-b-md bg-slate-600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</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>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import {Modal} from '@/components';
|
||||
import {formatIsk} from '@/formaters';
|
||||
import {MarketType, MarketTypeLabel} from '@/market';
|
||||
import {ref} from 'vue';
|
||||
import {useAcquiredTypesStore} from './acquisition';
|
||||
|
||||
const acquiredTypesStore = useAcquiredTypesStore();
|
||||
|
||||
const modalOpen = ref<boolean>(false);
|
||||
const type = ref<MarketType>();
|
||||
const suggestions = ref<Record<string, number>>({});
|
||||
const price = ref(1000000);
|
||||
const count = ref(1);
|
||||
|
||||
const open = (t: MarketType, s?: Record<string, number> | number) => {
|
||||
type.value = t;
|
||||
count.value = 1;
|
||||
|
||||
if (typeof s === 'number') {
|
||||
suggestions.value = {};
|
||||
price.value = s;
|
||||
} else if (s) {
|
||||
suggestions.value = s;
|
||||
price.value = Object.values(s)[0];
|
||||
} else {
|
||||
suggestions.value = {};
|
||||
price.value = 1000000;
|
||||
}
|
||||
modalOpen.value = true;
|
||||
}
|
||||
const add = () => {
|
||||
const id = type.value?.id;
|
||||
|
||||
if (!id) {
|
||||
modalOpen.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
acquiredTypesStore.addAcquiredType(id, count.value, price.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">
|
||||
<span>Price: </span>
|
||||
<div class="ms-2">
|
||||
<input type="number" min="0" step="1" v-model="price" @keyup.enter="add" />
|
||||
<div class="px-2 mt-2 bg-slate-600 hover:bg-slate-700 border rounded cursor-pointer" v-for="(p, n) of suggestions" :key="n" @click="price = p">{{ n }}: {{ formatIsk(p) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<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="add" />
|
||||
</div>
|
||||
<button class="mb-auto" @click="add">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MarketType } from "../type";
|
||||
|
||||
|
||||
export type MarketTypePrice = {
|
||||
type: MarketType;
|
||||
buy: number;
|
||||
sell: number;
|
||||
orderCount: number;
|
||||
};
|
||||
|
||||
export type PriceGetter = (types: MarketType[]) => Promise<MarketTypePrice[]>;
|
||||
@@ -0,0 +1,47 @@
|
||||
import {defineStore} from 'pinia';
|
||||
import {RegionalMarketCache} from '../RegionalMarketCache';
|
||||
import {jitaId} from '../market';
|
||||
import {MarketType} from "../type";
|
||||
import {MarketTypePrice} from './MarketTypePrice';
|
||||
import {getMammonPrices} from './mammon';
|
||||
|
||||
const CACHE_DURATION = 1000 * 60 * 5; // 5 minutes
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
export const useApraisalStore = defineStore('appraisal', () => {
|
||||
const cache: RegionalMarketCache<MarketTypePrice> = new RegionalMarketCache(CACHE_DURATION);
|
||||
|
||||
const getPricesUncached = getMammonPrices;
|
||||
|
||||
const getPrice = async (type: MarketType, regionId?: number): Promise<MarketTypePrice> => (await getPrices([type], regionId))[0];
|
||||
const getPrices = async (types: MarketType[], regionId?: number): Promise<MarketTypePrice[]> => {
|
||||
const cached: MarketTypePrice[] = [];
|
||||
const uncached: MarketType[] = [];
|
||||
const rId = regionId ?? jitaId;
|
||||
|
||||
types.forEach(t => {
|
||||
const cachedPrice = cache.get(rId, t.id);
|
||||
|
||||
if (cachedPrice) {
|
||||
cached.push(cachedPrice);
|
||||
} else {
|
||||
uncached.push(t);
|
||||
}
|
||||
});
|
||||
|
||||
if (uncached.length > 0) {
|
||||
const batches: Promise<MarketTypePrice[]>[] = [];
|
||||
|
||||
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;
|
||||
};
|
||||
return { getPrice, getPrices };
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import {logResource} from '@/service';
|
||||
import axios from 'axios';
|
||||
|
||||
export const evepraisalAxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_EVEPRAISAL_URL,
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
})
|
||||
logResource(evepraisalAxiosInstance)
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './MarketTypePrice';
|
||||
export * from './appraisal';
|
||||
@@ -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;
|
||||
}, []);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { MarketHistory } from "@/market";
|
||||
|
||||
export type HistoryQuartils = {
|
||||
totalVolume: number,
|
||||
q1: number,
|
||||
median: number,
|
||||
q3: number,
|
||||
}
|
||||
|
||||
export const getHistoryQuartils = (history: MarketHistory[], days?: number): HistoryQuartils => {
|
||||
const now = Date.now();
|
||||
|
||||
const volumes = history
|
||||
.flatMap(h => {
|
||||
const volume = h.volume;
|
||||
|
||||
if (volume === 0 || (days && new Date(h.date).getTime() < now - days * 24 * 60 * 60 * 1000)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const e = estimateVolume(h);
|
||||
|
||||
return [[h.highest, e], [h.lowest, volume - e]];
|
||||
})
|
||||
.filter(h => h[1] > 0)
|
||||
.sort((a, b) => a[0] - b[0]);
|
||||
|
||||
const totalVolume = volumes.reduce((acc, [_, v]) => acc + v, 0);
|
||||
const quartilVolume = totalVolume / 4;
|
||||
const quartils: [number, number, number] = [0, 0, 0];
|
||||
|
||||
let currentVolume = 0;
|
||||
let quartil = 0;
|
||||
|
||||
for (const [price, volume] of volumes) {
|
||||
currentVolume += volume;
|
||||
|
||||
if (currentVolume >= quartilVolume * (quartil + 1)) {
|
||||
quartils[quartil] = price;
|
||||
if (quartil === 2) {
|
||||
break;
|
||||
}
|
||||
quartil++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
totalVolume,
|
||||
q1: quartils[0],
|
||||
median: quartils[1],
|
||||
q3: quartils[2],
|
||||
};
|
||||
}
|
||||
|
||||
const estimateVolume = (history: MarketHistory): number => {
|
||||
if (history.volume === 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(1, Math.round(history.volume * ((history.average - history.lowest) / (history.highest - history.lowest))));
|
||||
}
|
||||
@@ -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';
|
||||
@@ -1,2 +1,10 @@
|
||||
export * from './RegionalMarketCache';
|
||||
export * from './history';
|
||||
export * from './tax';
|
||||
export * from './type';
|
||||
|
||||
export * from './appraisal';
|
||||
export * from './market';
|
||||
|
||||
export { default as IskLabel } from './IskLabel.vue';
|
||||
|
||||
|
||||
@@ -1,78 +1 @@
|
||||
import { esiAxiosInstance } from "@/service";
|
||||
import { MarketType } from "./type";
|
||||
|
||||
export const jitaId = 10000002;
|
||||
|
||||
export type MarketOrderHistory = {
|
||||
average: number;
|
||||
date: string;
|
||||
highest: number;
|
||||
lowest: number;
|
||||
order_count: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export type MarketResult = {
|
||||
type: MarketType,
|
||||
q1: number,
|
||||
median: number,
|
||||
q3: number,
|
||||
}
|
||||
|
||||
export type HistoryQuartils = {
|
||||
totalVolume: number,
|
||||
q1: number,
|
||||
median: number,
|
||||
q3: number,
|
||||
}
|
||||
|
||||
export const getHistory = async (regionId: number, tyeId: number): Promise<MarketOrderHistory[]> => (await esiAxiosInstance.get(`/markets/${regionId}/history/`, { params: { type_id: tyeId } })).data;
|
||||
|
||||
export const getHistoryQuartils = (history: MarketOrderHistory[]): HistoryQuartils => {
|
||||
const volumes = history
|
||||
.flatMap(h => {
|
||||
const volume = h.volume;
|
||||
|
||||
if (volume === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const e = estimateVolume(h);
|
||||
|
||||
return [[h.highest, e], [h.lowest, volume - e]];
|
||||
})
|
||||
.filter(h => h[1] > 0)
|
||||
.sort((a, b) => a[0] - b[0]);
|
||||
|
||||
const totalVolume = volumes.reduce((acc, [_, v]) => acc + v, 0);
|
||||
const quartilVolume = totalVolume / 4;
|
||||
const quartils: [number, number, number] = [0, 0, 0];
|
||||
|
||||
let currentVolume = 0;
|
||||
let quartil = 0;
|
||||
|
||||
for (const [price, volume] of volumes) {
|
||||
currentVolume += volume;
|
||||
|
||||
if (currentVolume >= quartilVolume * (quartil + 1)) {
|
||||
quartils[quartil] = price;
|
||||
if (quartil === 2) {
|
||||
break;
|
||||
}
|
||||
quartil++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
totalVolume,
|
||||
q1: quartils[0],
|
||||
median: quartils[1],
|
||||
q3: quartils[2],
|
||||
};
|
||||
}
|
||||
|
||||
const estimateVolume = (history: MarketOrderHistory): number => {
|
||||
if (history.volume === 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(1, Math.round(history.volume * ((history.average - history.lowest) / (history.highest - history.lowest))));
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import {SliderCheckbox} from '@/components';
|
||||
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
|
||||
import {formatIsk, percentFormater} from "@/formaters";
|
||||
import {MarketType, MarketTypeLabel} from "@/market";
|
||||
import {ShoppingCartIcon} from '@heroicons/vue/24/outline';
|
||||
import {useStorage} from '@vueuse/core';
|
||||
import {computed, ref} from 'vue';
|
||||
import {useAcquiredTypesStore} from '../acquisition';
|
||||
import {ScanResult} from './scan';
|
||||
|
||||
type Result = {
|
||||
type: MarketType;
|
||||
typeID: number;
|
||||
name: string;
|
||||
buy: number;
|
||||
sell: number;
|
||||
q1: number;
|
||||
median: number;
|
||||
q3: number;
|
||||
totalVolume: number;
|
||||
acquisitions: number;
|
||||
profit: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items?: ScanResult[];
|
||||
infoOnly?: boolean;
|
||||
ignoredColums?: string[] | string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'buy', type: MarketType, buy: number, sell: number): void;
|
||||
}
|
||||
|
||||
const scoreFormater = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 0
|
||||
});
|
||||
const volumeFormater = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 0
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
items: () => [],
|
||||
infoOnly: false,
|
||||
ignoredColums: () => []
|
||||
});
|
||||
defineEmits<Emits>();
|
||||
|
||||
const acquiredTypesStore = useAcquiredTypesStore();
|
||||
|
||||
const threshold = useStorage('market-scan-threshold', 10);
|
||||
const filter = ref("");
|
||||
const onlyCheap = ref(false);
|
||||
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 { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => props.items
|
||||
.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase()))
|
||||
.map(r => {
|
||||
const acquisitions = columnsToIgnore.value.includes('acquisitions') ? 0 : acquiredTypesStore.acquiredTypes
|
||||
.filter(t => t.type === r.type.id)
|
||||
.reduce((a, b) => a + b.remaining, 0);
|
||||
|
||||
return {
|
||||
type: r.type,
|
||||
typeID: r.type.id,
|
||||
name: r.type.name,
|
||||
buy: r.buy,
|
||||
sell: r.sell,
|
||||
q1: r.q1,
|
||||
median: r.median,
|
||||
q3: r.q3,
|
||||
totalVolume: r.totalVolume,
|
||||
acquisitions,
|
||||
profit: r.profit,
|
||||
score: r.score
|
||||
};
|
||||
}).filter(r => !onlyCheap.value || (r.buy <= r.q1 && r.profit >= (threshold.value / 100)))), {
|
||||
defaultSortKey: 'score',
|
||||
defaultSortDirection: 'desc',
|
||||
ignoredColums: columnsToIgnore
|
||||
})
|
||||
const getLineColor = (result: Result) => {
|
||||
if (props.infoOnly) {
|
||||
return '';
|
||||
} else if (result.profit < (threshold.value / 100)) {
|
||||
return 'line-red';
|
||||
} else if (result.sell > 0 && result.sell <= result.q1) {
|
||||
return 'line-blue';
|
||||
} else if (result.buy <= result.q1) {
|
||||
return 'line-green';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!infoOnly" class="flex mb-2 mt-4">
|
||||
<div class="flex justify-self-end ms-auto">
|
||||
<div class="end">
|
||||
<span>Profit Threshold: </span>
|
||||
<input type="number" min="0" max="1000" step="1" v-model="threshold" />
|
||||
</div>
|
||||
<div class="end flex">
|
||||
<SliderCheckbox class="me-1" v-model="onlyCheap" /> Show only cheap items
|
||||
</div>
|
||||
<div class="end">
|
||||
<span>Filter: </span>
|
||||
<input type="search" class="w-96" v-model="filter" />
|
||||
</div>
|
||||
</div>
|
||||
</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="buy">Buy</SortableHeader>
|
||||
<SortableHeader v-bind="headerProps" sortKey="sell">Sell</SortableHeader>
|
||||
<SortableHeader v-bind="headerProps" sortKey="q1">Q1</SortableHeader>
|
||||
<SortableHeader v-bind="headerProps" sortKey="median">Median</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="score">Score</SortableHeader>
|
||||
<SortableHeader v-bind="headerProps" sortKey="acquisitions">Acquisitions</SortableHeader>
|
||||
<SortableHeader v-bind="headerProps" sortKey="buttons" unsortable />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in list" :key="r.data.typeID" :class="getLineColor(r.data)">
|
||||
<td v-if="showColumn('name')">
|
||||
<MarketTypeLabel :id="r.data.typeID" :name="r.data.name" />
|
||||
</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('q1')" class="text-right">{{ formatIsk(r.data.q1) }}</td>
|
||||
<td v-if="showColumn('median')" class="text-right">{{ formatIsk(r.data.median) }}</td>
|
||||
<td v-if="showColumn('q3')" class="text-right">{{ formatIsk(r.data.q3) }}</td>
|
||||
<td v-if="showColumn('totalVolume')" class="text-right">{{ volumeFormater.format(r.data.totalVolume) }}</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">
|
||||
<button class="btn-icon me-1" title="Add acquisitions" @click="$emit('buy', r.data.type, r.data.buy, r.data.sell)"><ShoppingCartIcon /></button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</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>
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './scan';
|
||||
|
||||
export { default as ScanResultTable } from './ScanResultTable.vue';
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MarketType } from "@/market";
|
||||
import { MarketScanResponse } from "@/generated/mammon";
|
||||
|
||||
export type ScanResult = {
|
||||
type: MarketType;
|
||||
buy: number;
|
||||
sell: number;
|
||||
q1: number;
|
||||
median: number;
|
||||
q3: number;
|
||||
totalVolume: number;
|
||||
profit: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export const toScanResult = (res: MarketScanResponse, type: MarketType): ScanResult => ({
|
||||
type,
|
||||
buy: res.buy,
|
||||
sell: res.sell,
|
||||
q1: res.q1,
|
||||
median: res.median,
|
||||
q3: res.q3,
|
||||
totalVolume: res.totalVolume,
|
||||
profit: res.profit,
|
||||
score: res.score,
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import {useMarketTaxStore} from "./tax";
|
||||
|
||||
const marketTaxStore = useMarketTaxStore();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="end">
|
||||
<span>Broker Fee: </span>
|
||||
<input type="number" min="1" max="3" step="0.01" v-model="marketTaxStore.brokerFee" />
|
||||
</div>
|
||||
<div class="end">
|
||||
<span>SCC: </span>
|
||||
<input type="number" min="3.6" max="8" step="0.01" v-model="marketTaxStore.scc" >
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/style.css";
|
||||
div.end {
|
||||
@apply justify-self-end ms-2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './tax';
|
||||
|
||||
export { default as TaxInput } from './TaxInput.vue';
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useMarketTaxStore = defineStore("marketTax", () => {
|
||||
const brokerFee = useLocalStorage("market-brokerFee", 1.5);
|
||||
const scc = useLocalStorage("market-scc", 3.6);
|
||||
|
||||
const applyTaxes = (price: number, sellOrder?: boolean) => sellOrder ? price * (1 - (brokerFee.value + scc.value) / 100) : price * (1 + brokerFee.value / 100);
|
||||
const calculateProfit = (buy: number, sell: number) => (applyTaxes(sell, true) / applyTaxes(buy)) - 1;
|
||||
|
||||
return { brokerFee, scc, applyTaxes, calculateProfit };
|
||||
});
|
||||
@@ -1,17 +1,40 @@
|
||||
import { apiAxiosInstance } from "@/service";
|
||||
import {marketApi} from '@/mammon/mammonService';
|
||||
import type {MarketTypeResponse} from '@/generated/mammon';
|
||||
|
||||
export type MarketType = {
|
||||
id: number;
|
||||
group_id: number;
|
||||
marketgroup_id: number;
|
||||
name: string;
|
||||
published: boolean;
|
||||
description: string;
|
||||
basePrice: number;
|
||||
icon_id: number;
|
||||
volume: number;
|
||||
portionSize: number;
|
||||
export type MarketType = MarketTypeResponse;
|
||||
|
||||
const cache = new Map<number, MarketType>(); // TODO move to pinia store
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
const fetchTypes = async (ids: number[]): Promise<void> => {
|
||||
const missing = ids.filter(id => !cache.has(id));
|
||||
if (missing.length === 0) {
|
||||
return;
|
||||
}
|
||||
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 getMarketTypes = async (types: (string | number)[]): Promise<MarketType[]> => {
|
||||
if (types.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const ids = types.filter((t): t is number => typeof t === 'number');
|
||||
await fetchTypes(ids);
|
||||
return ids.map(id => cache.get(id)).filter((t): t is MarketType => t !== undefined);
|
||||
}
|
||||
|
||||
export const getMarketType = async (id: number): Promise<MarketType> => (await apiAxiosInstance.get<MarketType>(`/sde/types/${id}/`)).data;
|
||||
export const searchMarketType = async (name: string): Promise<MarketType> => (await apiAxiosInstance.post<MarketType[]>("/sde/types/search", [["name", name]])).data[0];
|
||||
export const searchMarketTypes = async (search: string): Promise<MarketType[]> => {
|
||||
if (search.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const types = await marketApi.searchTypes(search).then(r => r.data);
|
||||
types.forEach(t => cache.set(t.id, t));
|
||||
return types;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import {vOnClickOutside} from '@vueuse/components';
|
||||
import {useVirtualList} from '@vueuse/core';
|
||||
import log from 'loglevel';
|
||||
import {nextTick, ref, watch, watchEffect} from 'vue';
|
||||
import {MarketType, searchMarketTypes} from './MarketType';
|
||||
import MarketTypeLabel from "./MarketTypeLabel.vue";
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit'): void;
|
||||
}
|
||||
|
||||
const modelValue = defineModel<MarketType>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const isOpen = ref(false);
|
||||
const name = ref('');
|
||||
const suggestions = ref<MarketType[]>([]);
|
||||
const currentIndex = ref(-1);
|
||||
const {list, scrollTo, containerProps, wrapperProps} = useVirtualList(suggestions, {
|
||||
itemHeight: 24,
|
||||
overscan: 3
|
||||
});
|
||||
|
||||
const moveDown = () => {
|
||||
if (currentIndex.value < 0 || currentIndex.value >= suggestions.value.length - 1) {
|
||||
currentIndex.value = 0;
|
||||
} else if (currentIndex.value < suggestions.value.length - 1) {
|
||||
currentIndex.value++;
|
||||
}
|
||||
scrollTo(currentIndex.value);
|
||||
}
|
||||
const moveUp = () => {
|
||||
if (currentIndex.value <= 0) {
|
||||
currentIndex.value = suggestions.value.length - 1;
|
||||
} else if (currentIndex.value > 0) {
|
||||
currentIndex.value--;
|
||||
}
|
||||
scrollTo(currentIndex.value);
|
||||
}
|
||||
const select = (type?: MarketType) => {
|
||||
log.debug('Select:', type);
|
||||
modelValue.value = type;
|
||||
currentIndex.value = -1;
|
||||
suggestions.value = [];
|
||||
isOpen.value = false;
|
||||
name.value = type?.name ?? '';
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}
|
||||
const submit = async () => {
|
||||
if (currentIndex.value >= 0 && currentIndex.value < suggestions.value.length) {
|
||||
const v = suggestions.value[currentIndex.value];
|
||||
|
||||
select(v);
|
||||
await nextTick();
|
||||
} else if (modelValue.value === undefined && suggestions.value.length > 0) {
|
||||
select(suggestions.value[0]);
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
if (modelValue.value === undefined) {
|
||||
return;
|
||||
}
|
||||
emit('submit');
|
||||
}
|
||||
|
||||
watch(() => modelValue.value, async v => {
|
||||
if (v === undefined) {
|
||||
name.value = '';
|
||||
} else {
|
||||
name.value = v.name;
|
||||
}
|
||||
})
|
||||
watchEffect(async () => {
|
||||
const search = name.value.split('\t')[0];
|
||||
|
||||
if (!isOpen.value || search.length < 3) {
|
||||
suggestions.value = [];
|
||||
} else {
|
||||
suggestions.value = await searchMarketTypes(search);
|
||||
scrollTo(0);
|
||||
}
|
||||
currentIndex.value = -1;
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div @click="() => isOpen = true" v-on-click-outside="() => isOpen = false">
|
||||
<div class="fake-input">
|
||||
<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" />
|
||||
</div>
|
||||
<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="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)">
|
||||
<MarketTypeLabel :id="s.data.id" :name="s.data.name" class="whitespace-nowrap overflow-hidden cursor-pointer" hideCopy />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/style.css";
|
||||
.fake-input {
|
||||
@apply w-96 flex border bg-slate-500 rounded px-1 py-0.5;
|
||||
|
||||
&:has(> input:focus-visible) {
|
||||
outline: -webkit-focus-ring-color auto 1px;
|
||||
}
|
||||
|
||||
> input {
|
||||
@apply w-full border-none bg-transparent block focus-visible:outline-none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
> img {
|
||||
@apply inline-block w-5 h-5 mt-1 me-1;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,19 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
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 {
|
||||
name?: string;
|
||||
id?: number;
|
||||
hideCopy?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
name: "",
|
||||
id: 0
|
||||
id: 0,
|
||||
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>
|
||||
|
||||
<template>
|
||||
<img v-if="id" :src="`https://images.evetech.net/types/${id}/icon`" class="inline-block w-5 h-5" />
|
||||
<template v-if="name">
|
||||
{{ name }}
|
||||
</template>
|
||||
</template>
|
||||
<div v-if="id || computedName" class="flex flex-row">
|
||||
<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="computedName">
|
||||
{{ computedName }}
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/style.css";
|
||||
|
||||
button:deep(>svg), .btn-icon:deep(>svg) {
|
||||
@apply !w-4 !h-4;
|
||||
}
|
||||
</style>
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './MarketType';
|
||||
export { default as MarketTypeLabel } from './MarketTypeLabel.vue';
|
||||
export { default as MarketTypeInput } from './MarketTypeInput.vue';
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<span>EVE Online and the EVE logo are the registered trademarks of CCP hf. All rights are reserved worldwide. All other trademarks are the property of their respective owners. EVE Online, the EVE logo, EVE and all associated logos and designs are the intellectual property of CCP hf. All artwork, screenshots, characters, vehicles, storylines, world facts or other recognizable features of the intellectual property relating to these trademarks are likewise the intellectual property of CCP hf. CCP hf. has granted permission to Eveal to use EVE Online and all associated logos and designs for promotional and information purposes on its website but does not endorse, and is not in any way affiliated with, Eveal. CCP is in no way responsible for the content on or functioning of this website, nor can it be liable for any damage arising from the use of this website.</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,21 @@
|
||||
<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.marketTypes}" class="tab">
|
||||
<span>Item Info</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/market/scan" class="tab">
|
||||
<span>Scan</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/market/acquisitions" class="tab">
|
||||
<span>Acquisitions</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,17 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ReprocessInput, ReprocessItemValues, ReprocessResultTable, reprocess } from '@/reprocess';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { ref } from 'vue';
|
||||
import ReprocessInput from './ReprocessInput.vue';
|
||||
import ReprocessResultTable from './ReprocessResultTable.vue';
|
||||
import { ReprocessItemValues, reprocess } from './reprocess';
|
||||
|
||||
const items = ref("");
|
||||
const minerals = ref("");
|
||||
const materials = ref("");
|
||||
const efficiency = useStorage('reprocess-efficiency', 0.55);
|
||||
|
||||
const result = ref<ReprocessItemValues[]>([]);
|
||||
|
||||
const send = async () => result.value = await reprocess(items.value, minerals.value, efficiency.value);
|
||||
const send = async () => result.value = await reprocess(items.value, materials.value, efficiency.value);
|
||||
|
||||
</script>
|
||||
|
||||
@@ -24,7 +22,7 @@ const send = async () => result.value = await reprocess(items.value, minerals.va
|
||||
</div>
|
||||
<div class="flex items-stretch">
|
||||
<ReprocessInput name="Item JSON" v-model="items" />
|
||||
<ReprocessInput name="Mineral JSON" v-model="minerals" />
|
||||
<ReprocessInput name="Materials JSON" v-model="materials" />
|
||||
</div>
|
||||
<div class="grid my-2">
|
||||
<button class="justify-self-end" @click="send">Send</button>
|
||||
@@ -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>
|
||||
@@ -1,9 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import HaulerTank from './HaulerTank.vue';
|
||||
import ModuleDamage from './ModuleDamage.vue';
|
||||
|
||||
|
||||
|
||||
import { HaulerTank, ModuleDamage } from '@/tools';
|
||||
</script>
|
||||
|
||||
<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(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>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import {getMarketTypes, TaxInput, useMarketTaxStore} from "@/market";
|
||||
import {BuyModal} from '@/market/acquisition';
|
||||
import {ScanResult, ScanResultTable, toScanResult} from '@/market/scan';
|
||||
import {marketApi} from "@/mammon";
|
||||
import {useStorage} from "@vueuse/core";
|
||||
import {ref, watch} from 'vue';
|
||||
|
||||
const buyModal = ref<typeof BuyModal>();
|
||||
|
||||
const marketTaxStore = useMarketTaxStore();
|
||||
const days = useStorage('market-scan-days', 365);
|
||||
const items = ref<ScanResult[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const scan = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
watch([days, () => marketTaxStore.brokerFee, () => marketTaxStore.scc], scan, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex mb-2 mt-4">
|
||||
<div class="flex justify-self-end ms-auto">
|
||||
<TaxInput />
|
||||
<div class="end">
|
||||
<span>Days: </span>
|
||||
<input type="number" min="1" max="365" step="1" v-model="days" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<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" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/style.css";
|
||||
div.end {
|
||||
@apply justify-self-end ms-2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import {ClipboardButton} from '@/components';
|
||||
import {getMarketType, MarketType, MarketTypeInput, useApraisalStore, useMarketTaxStore} from "@/market";
|
||||
import {AcquisitionResultTable, BuyModal, useAcquiredTypesStore} from '@/market/acquisition';
|
||||
import {ScanResultTable, toScanResult} from '@/market/scan';
|
||||
import {marketApi} from "@/mammon";
|
||||
import {ShoppingCartIcon} from '@heroicons/vue/24/outline';
|
||||
import log from "loglevel";
|
||||
import {computed, ref, watch} from "vue";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import {routeNames} from "@/routes";
|
||||
import {computedAsync, useStorage} from "@vueuse/core";
|
||||
|
||||
const buyModal = ref<typeof BuyModal>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const item = ref<MarketType>();
|
||||
const inputItem = ref<MarketType>();
|
||||
|
||||
const apraisalStore = useApraisalStore();
|
||||
const marketTaxStore = useMarketTaxStore();
|
||||
const days = useStorage('market-scan-days', 365);
|
||||
const price = computedAsync(() => item.value ? apraisalStore.getPrice(item.value) : undefined);
|
||||
const result = computedAsync(async () => {
|
||||
if (!item.value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
if (!inputItem.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push({
|
||||
name: routeNames.marketTypes,
|
||||
params: {
|
||||
type: inputItem.value.id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(useRoute(), async route => {
|
||||
if (route.params.type) {
|
||||
const id = parseInt(typeof route.params.type === 'string' ? route.params.type : route.params.type[0]);
|
||||
|
||||
item.value = await getMarketType(id);
|
||||
inputItem.value = item.value;
|
||||
log.info('Loaded item:', item.value);
|
||||
} else {
|
||||
item.value = undefined;
|
||||
inputItem.value = undefined;
|
||||
log.info('No item to load');
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid mb-2 mt-4">
|
||||
<div class="w-auto flex">
|
||||
<span>Item: </span>
|
||||
<MarketTypeInput class="ms-2" v-model="inputItem" @submit="view"/>
|
||||
<button class="justify-self-end ms-2" @click="view">View</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="item">
|
||||
<hr>
|
||||
<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" alt="" />
|
||||
<div class="inline-block align-top w-full">
|
||||
<div class="flex">
|
||||
<span class="text-lg font-semibold">{{ item.name }}</span>
|
||||
<div class="ms-auto">
|
||||
<ClipboardButton class="ms-1" :value="item.name" />
|
||||
<button v-if="price" class="btn-icon ms-1" title="Add acquisitions" @click="buyModal?.open(item, { 'Buy': price.buy, 'Sell': price.sell })"><ShoppingCartIcon /></button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="item.description" class="text-sm">{{ item.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<BuyModal ref="buyModal" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/style.css";
|
||||
|
||||
img.type-image {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
</style>
|
||||
@@ -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,24 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core';
|
||||
|
||||
interface Props {
|
||||
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);
|
||||
const modelValue = defineModel({ default: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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-8"> Sell </span>
|
||||
<span class="w-7 h-7 right-7 absolute rounded-full transform transition-transform bg-slate-100" />
|
||||
@@ -26,6 +13,7 @@ const value = useVModel(props, 'modelValue', emit);
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/style.css";
|
||||
input:checked ~ span:last-child {
|
||||
--tw-translate-x: 1.75rem;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { evepraisalAxiosInstance } from '@/service';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { evepraisalAxiosInstance } from '@/market/appraisal/evepraisal';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
modelValue?: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: ''
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const value = useVModel(props, 'modelValue', emit);
|
||||
const modelValue = defineModel({ default: '' });
|
||||
defineProps<Props>();
|
||||
|
||||
const loadFromId = async (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
@@ -31,7 +21,7 @@ const loadFromId = async (e: Event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
value.value = JSON.stringify(response.data);
|
||||
modelValue.value = JSON.stringify(response.data);
|
||||
input.value = '';
|
||||
}
|
||||
</script>
|
||||
@@ -39,6 +29,6 @@ const loadFromId = async (e: Event) => {
|
||||
<template>
|
||||
<div class="flex-1 mx-1">
|
||||
<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>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user