Compare commits
264
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
617650929a | ||
|
|
b70585f01d | ||
|
|
f7a193d1c5 | ||
|
|
0040a5c33b | ||
|
|
fb9567eee5 | ||
|
|
3419f3d4c9 | ||
|
|
d1237bf1d2 | ||
|
|
d4b48b05ee | ||
|
|
38e1063ac5 | ||
|
|
04a9f197e8 | ||
|
|
27b7cecfdc | ||
|
|
e814aabcc8 | ||
|
|
4c8a3d5dfc | ||
|
|
187c6b0f99 | ||
|
|
24c7ec4917 | ||
|
|
0d61826c70 | ||
|
|
364f711a97 | ||
|
|
777cb74574 | ||
|
|
52addba909 | ||
|
|
81250953eb | ||
|
|
c4803ef650 | ||
|
|
0f97a49e26 | ||
|
|
5a974bac8f | ||
|
|
98380a06b9 | ||
|
|
10f01a9346 | ||
|
|
7caa45a1fb | ||
|
|
a22845add8 | ||
|
|
e8e7c4f642 | ||
|
|
da7a729321 | ||
|
|
55c2628c75 | ||
|
|
75d1d5f7fa | ||
|
|
2622d9f01e | ||
|
|
a7407b8e06 | ||
|
|
dadcf97bec | ||
|
|
d2a3a22ddf | ||
|
|
2a145bbbe7 | ||
|
|
fbe0861c29 | ||
|
|
c1f62f66e1 | ||
|
|
698156cf00 | ||
|
|
8d73fe3ed1 | ||
|
|
052a0a5d03 | ||
|
|
e66fe9b40e | ||
|
|
8a71dad977 | ||
|
|
45ed773119 | ||
|
|
a0cc120fec | ||
|
|
61fbc8e16b | ||
|
|
c1993d27b2 | ||
|
|
e7249df23f | ||
|
|
925d9eef73 | ||
|
|
35b2c9539a | ||
|
|
9f6cc616ad | ||
|
|
9edb0e6ce8 | ||
|
|
5e7af075cb | ||
|
|
6dac42bbbe | ||
|
|
981045da2f | ||
|
|
35779d2b3d | ||
|
|
4b24f433a5 | ||
|
|
dcf50fb8af | ||
|
|
cc4d56ae4c | ||
|
|
697a08e481 | ||
|
|
16078cc62b | ||
|
|
3a35d2181d | ||
|
|
6d2b5926bb | ||
|
|
cc3bdccd9a | ||
|
|
3348b9f668 | ||
|
|
d0c198118d | ||
|
|
2ab3f01d89 | ||
|
|
3981475c55 | ||
|
|
acde42b406 | ||
|
|
7ca38aee70 | ||
|
|
dd031551ca | ||
|
|
f3cb4798d5 | ||
|
|
9cd0d5fb5e | ||
|
|
5ac369a643 | ||
|
|
b2c97c1327 | ||
|
|
4ae044dace | ||
|
|
c444f51423 | ||
|
|
a201a95756 | ||
|
|
b32169f433 | ||
|
|
023693c4c8 | ||
|
|
47bd728530 | ||
|
|
7e0ea10d68 | ||
|
|
cd1965acc4 | ||
|
|
653f7dbeeb | ||
|
|
5506125b2e | ||
|
|
e6ee697508 | ||
|
|
bef14bcdcc | ||
|
|
8d0e5ffc1a | ||
|
|
680e8d8b95 | ||
|
|
13ba8556a4 | ||
|
|
46a2538bef | ||
|
|
57b9ec17de | ||
|
|
af3b26a273 | ||
|
|
5da9003b14 | ||
|
|
ba548583ba | ||
|
|
5c12a8af43 | ||
|
|
05210fea4b | ||
|
|
f28201e711 | ||
|
|
c23ec0cb53 | ||
|
|
42c7e59d63 | ||
|
|
192cf7d9cb | ||
|
|
3235cf21ba | ||
|
|
47ee14319d | ||
|
|
457d2a5161 | ||
|
|
1358aaa705 | ||
|
|
d7bae268da | ||
|
|
9310397320 | ||
|
|
ba81d7b6a8 | ||
|
|
676ff961ed | ||
|
|
b40b58f866 | ||
|
|
9acbc101e1 | ||
|
|
ccc6b827f0 | ||
|
|
0a2894b378 | ||
|
|
2d6930d38d | ||
|
|
11fbe847f2 | ||
|
|
72933ada6e | ||
|
|
e10d58d231 | ||
|
|
4b39d491d2 | ||
|
|
a1dbe41b6c | ||
|
|
e233e609e6 | ||
|
|
d64b718573 | ||
|
|
153dff6bc7 | ||
|
|
4fbced2c70 | ||
|
|
2970f48e65 | ||
|
|
e137bec8dd | ||
|
|
3ca0cf23f1 | ||
|
|
02466eea14 | ||
|
|
f4b590bc3b | ||
|
|
65bb13aa3b | ||
|
|
2332ad2216 | ||
|
|
8005e7a45b | ||
|
|
f9ae0d142a | ||
|
|
e81fdc24bb | ||
|
|
778de8ca14 | ||
|
|
00c37c0a37 | ||
|
|
a56580ce27 | ||
|
|
11f886cd71 | ||
|
|
ac07236936 | ||
|
|
9aa37b355e | ||
|
|
12ad7d36ff | ||
|
|
c77a6ff811 | ||
|
|
0a82fca6d3 | ||
|
|
1e57e7c33e | ||
|
|
c484948a5e | ||
|
|
4748b15cc4 | ||
|
|
9ccba70ede | ||
|
|
1868b3e248 | ||
|
|
9f2627faf8 | ||
|
|
a7b1fb902c | ||
|
|
6afce2ef58 | ||
|
|
fff01ff30f | ||
|
|
a576a93a0b | ||
|
|
a33426f3c2 | ||
|
|
0dc309642c | ||
|
|
8dc1a2dc3c | ||
|
|
e477242f16 | ||
|
|
f75156bc62 | ||
|
|
e379f490a4 | ||
|
|
c210ed7fac | ||
|
|
92b7f60c75 | ||
|
|
7e7c638ef1 | ||
|
|
b19ef017d6 | ||
|
|
8bcbf3bd1d | ||
|
|
540d4814d9 | ||
|
|
884412f5a9 | ||
|
|
4814d24efb | ||
|
|
34095e0d38 | ||
|
|
bbad25b55b | ||
|
|
c76f4be928 | ||
|
|
d89ff4ea7f | ||
|
|
7a7dba010e | ||
|
|
b81282b42e | ||
|
|
617d3b281e | ||
|
|
c52e92e3ce | ||
|
|
a9e981baa0 | ||
|
|
8fdcc75826 | ||
|
|
d82f6b6965 | ||
|
|
3a3711b713 | ||
|
|
c1778b3d49 | ||
|
|
514c28b900 | ||
|
|
09a3295920 | ||
|
|
400737dab8 | ||
|
|
27f146b945 | ||
|
|
fb9a2f11fe | ||
|
|
4e211c8834 | ||
|
|
79bef2775c | ||
|
|
3fd4f5080d | ||
|
|
f677a1d61b | ||
|
|
d5aafc88a9 | ||
|
|
52a4b99214 | ||
|
|
ff4c9c6bf0 | ||
|
|
2756bbb2c2 | ||
|
|
6c99fa0401 | ||
|
|
c38f44c182 | ||
|
|
167788ac15 | ||
|
|
2c64cca921 | ||
|
|
894b23166c | ||
|
|
e3a5eeb50d | ||
|
|
5887ecb638 | ||
|
|
78c96f8bce | ||
|
|
5ddee59227 | ||
|
|
94992afbe3 | ||
|
|
717eaa6ed8 | ||
|
|
9fb78329cc | ||
|
|
af9465c127 | ||
|
|
5c0b83a0a3 | ||
|
|
b1da083557 | ||
|
|
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 |
@@ -23,3 +23,5 @@ docker-compose.yml
|
||||
*.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.template /etc/nginx/templates/default.conf.template
|
||||
RUN mkdir etc/nginx/templates && \
|
||||
mv /usr/share/nginx/html/index.html /etc/nginx/templates/index.html.template
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
+2349
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
server {
|
||||
listen 80 http2;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ $uri.html /index.html;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ $uri.html /index.html;
|
||||
}
|
||||
location /api/ {
|
||||
proxy_pass https://${API_URL}/;
|
||||
rewrite /api/(.*) /$1 break;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header Host "${API_URL}";
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
|
||||
}
|
||||
location /appraisal/ {
|
||||
proxy_pass https://${EVEPRAISAL_URL}/;
|
||||
rewrite /appraisal/(.*) /$1 break;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header Host "${EVEPRAISAL_URL}";
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
location /esi/ {
|
||||
proxy_pass https://esi.evetech.net/;
|
||||
rewrite /esi/(.*) /latest/$1 break;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header Host "esi.evetech.net";
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header User-Agent "${ESI_USER_AGENT}";
|
||||
}
|
||||
}
|
||||
Generated
+2606
-1747
File diff suppressed because it is too large
Load Diff
+26
-15
@@ -1,30 +1,41 @@
|
||||
{
|
||||
"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": {
|
||||
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
|
||||
"@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",
|
||||
"chart.js": "^4.5.1",
|
||||
"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-chartjs": "^5.3.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,8 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
'tailwindcss/nesting': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
+34
-2
@@ -1,16 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import { Sidebar } from './sidebar';
|
||||
import {computed, watchEffect} from 'vue';
|
||||
import {RouterView, useRoute} from 'vue-router';
|
||||
import {Sidebar} from './sidebar';
|
||||
import {ConfirmModal} from '@/confirm';
|
||||
import {toast, ToastContainer} from '@/toast';
|
||||
import {onActivitiesProcessed} from '@/activity';
|
||||
import {useAuthStore} from '@/auth';
|
||||
import {routeNames} from '@/routes';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const hideSidebar = computed(() => {
|
||||
return route.name === routeNames.callback || route.name === routeNames.about;
|
||||
});
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
watchEffect(onCleanup => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
const unsubscribe = onActivitiesProcessed(() => toast.info('New activities have been processed'));
|
||||
|
||||
onCleanup(unsubscribe);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="hideSidebar">
|
||||
<RouterView />
|
||||
</template>
|
||||
<template v-else>
|
||||
<Sidebar />
|
||||
<div class="main-container">
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
<ConfirmModal />
|
||||
<ToastContainer />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/style.css";
|
||||
|
||||
div.main-container {
|
||||
@apply px-4 sm:ml-64;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {computed} from "vue";
|
||||
import {computedAsync} from "@vueuse/core";
|
||||
import {ActivitySourceResponse, ActivitySourceResponseTypeEnum} from "@/generated/mammon";
|
||||
import {CharacterLabel, useCharactersStore} from "@/characters";
|
||||
import {CorporationLabel, useCorporationsStore} from "@/corporations";
|
||||
import {LedgerLabel, useLedgersStore} from "@/ledger";
|
||||
|
||||
interface Props {
|
||||
source: ActivitySourceResponse;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 32
|
||||
});
|
||||
|
||||
const charactersStore = useCharactersStore();
|
||||
const corporationsStore = useCorporationsStore();
|
||||
const ledgersStore = useLedgersStore();
|
||||
|
||||
const character = computedAsync(async () => {
|
||||
if (props.source.type === ActivitySourceResponseTypeEnum.Character && props.source.characterId) {
|
||||
return await charactersStore.findById(props.source.characterId);
|
||||
}
|
||||
return undefined;
|
||||
}, undefined);
|
||||
|
||||
const corporation = computedAsync(async () => {
|
||||
if (props.source.type === ActivitySourceResponseTypeEnum.Corporation && props.source.corporationId) {
|
||||
return await corporationsStore.findById(props.source.corporationId);
|
||||
}
|
||||
return undefined;
|
||||
}, undefined);
|
||||
|
||||
const ledger = computed(() =>
|
||||
props.source.type === ActivitySourceResponseTypeEnum.Ledger && props.source.ledgerId
|
||||
? ledgersStore.findById(props.source.ledgerId)
|
||||
: undefined);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CharacterLabel v-if="source.type === ActivitySourceResponseTypeEnum.Character && character" :character="character" :size="size" />
|
||||
<CorporationLabel v-else-if="source.type === ActivitySourceResponseTypeEnum.Corporation && source.corporationId" :corporation-id="source.corporationId" :corporation="corporation" :division="source.division" :size="size" />
|
||||
<LedgerLabel v-else-if="ledger" :ledger="ledger" link />
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
import log from "loglevel";
|
||||
import {activityApi} from "@/mammon";
|
||||
|
||||
const RECONNECT_DELAY_MILLIS = 3_000;
|
||||
const FRAME_SEPARATOR = "\n\n";
|
||||
const PROCESSED_EVENT = "event:processed";
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
let controller: AbortController | undefined;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const notifyProcessed = () => listeners.forEach(listener => listener());
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (listeners.size === 0) {
|
||||
return;
|
||||
}
|
||||
reconnectTimer = setTimeout(() => connect(), RECONNECT_DELAY_MILLIS);
|
||||
};
|
||||
|
||||
const consume = async (body: ReadableStream<Uint8Array>) => {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
for (; ;) {
|
||||
const {value, done} = await reader.read();
|
||||
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
buffer += decoder.decode(value, {stream: true});
|
||||
|
||||
let boundary = buffer.indexOf(FRAME_SEPARATOR);
|
||||
|
||||
while (boundary !== -1) {
|
||||
const frame = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + FRAME_SEPARATOR.length);
|
||||
|
||||
if (frame.split("\n").some(line => line.trim() === PROCESSED_EVENT)) {
|
||||
notifyProcessed();
|
||||
}
|
||||
boundary = buffer.indexOf(FRAME_SEPARATOR);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const connect = async (): Promise<void> => {
|
||||
if (listeners.size === 0) {
|
||||
return;
|
||||
}
|
||||
const signal = controller?.signal;
|
||||
|
||||
try {
|
||||
const response = await activityApi.streamProcessing({
|
||||
responseType: "stream",
|
||||
adapter: "fetch",
|
||||
signal,
|
||||
});
|
||||
|
||||
await consume(response.data as unknown as ReadableStream<Uint8Array>);
|
||||
scheduleReconnect();
|
||||
} catch (error) {
|
||||
if (listeners.size === 0 || signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
log.debug("Activity processing SSE error, reconnecting", error);
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = undefined;
|
||||
}
|
||||
controller?.abort();
|
||||
controller = undefined;
|
||||
};
|
||||
|
||||
export const onActivitiesProcessed = (listener: () => void): (() => void) => {
|
||||
listeners.add(listener);
|
||||
|
||||
if (!controller) {
|
||||
controller = new AbortController();
|
||||
connect();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (listeners.delete(listener) && listeners.size === 0) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export {default as SourceLabel} from './SourceLabel.vue';
|
||||
export {onActivitiesProcessed} from './activitiesProcessed';
|
||||
export {useActivitiesProcessed} from './useActivitiesProcessed';
|
||||
export {useProcessedResource} from './useProcessedResource';
|
||||
@@ -0,0 +1,8 @@
|
||||
import {onUnmounted} from "vue";
|
||||
import {onActivitiesProcessed} from "./activitiesProcessed";
|
||||
|
||||
export const useActivitiesProcessed = (listener: () => void): (() => void) => {
|
||||
const unsubscribe = onActivitiesProcessed(listener);
|
||||
onUnmounted(unsubscribe);
|
||||
return unsubscribe;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import {ref, type Ref, watchEffect} from "vue";
|
||||
import {useActivitiesProcessed} from "./useActivitiesProcessed";
|
||||
|
||||
export const useProcessedResource = <T>(loader: () => Promise<T>, initial: T): Ref<T> => {
|
||||
const data = ref(initial) as Ref<T>;
|
||||
|
||||
const load = async () => {
|
||||
data.value = await loader();
|
||||
};
|
||||
|
||||
watchEffect(load);
|
||||
useActivitiesProcessed(load);
|
||||
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import {defineStore} from "pinia";
|
||||
import {ref} from "vue";
|
||||
import {CharacterResponse} from "@/generated/mammon";
|
||||
import {
|
||||
addCharacter as addCharacterRequest,
|
||||
fetchMe,
|
||||
mammonLoginUrl,
|
||||
postLogout,
|
||||
refreshAccessToken,
|
||||
setOnAuthExpired
|
||||
} from "@/mammon";
|
||||
import {setAccessToken} from "./token";
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const userId = ref<string | null>(null);
|
||||
const characters = ref<CharacterResponse[]>([]);
|
||||
const isAuthenticated = ref(false);
|
||||
|
||||
const clear = () => {
|
||||
setAccessToken(null);
|
||||
userId.value = null;
|
||||
characters.value = [];
|
||||
isAuthenticated.value = false;
|
||||
}
|
||||
|
||||
const refresh = async (): Promise<boolean> => {
|
||||
const token = await refreshAccessToken();
|
||||
|
||||
if (!token) {
|
||||
clear();
|
||||
return false;
|
||||
}
|
||||
isAuthenticated.value = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
const fetch = async (): Promise<void> => {
|
||||
const me = await fetchMe();
|
||||
|
||||
userId.value = me.userId;
|
||||
characters.value = me.characters;
|
||||
isAuthenticated.value = true;
|
||||
}
|
||||
|
||||
const login = (): void => {
|
||||
window.location.assign(mammonLoginUrl);
|
||||
}
|
||||
|
||||
const addCharacter = async (): Promise<void> => {
|
||||
await addCharacterRequest();
|
||||
window.location.assign(mammonLoginUrl);
|
||||
}
|
||||
|
||||
const logout = async (): Promise<void> => {
|
||||
try {
|
||||
await postLogout();
|
||||
} finally {
|
||||
clear();
|
||||
}
|
||||
}
|
||||
|
||||
const bootstrap = async (): Promise<void> => {
|
||||
setOnAuthExpired(() => {
|
||||
clear();
|
||||
login();
|
||||
});
|
||||
|
||||
if (await refresh()) {
|
||||
await fetch().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
return {userId, characters, isAuthenticated, refresh, fetch, login, addCharacter, logout, bootstrap};
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './auth';
|
||||
export * from './token';
|
||||
@@ -0,0 +1,7 @@
|
||||
let accessToken: string | null = null;
|
||||
|
||||
export const getAccessToken = (): string | null => accessToken;
|
||||
|
||||
export const setAccessToken = (token: string | null): void => {
|
||||
accessToken = token;
|
||||
};
|
||||
@@ -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,26 @@
|
||||
import {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 refresh = () => characterApi.findAllCharacters().then(response => characters.value = response.data);
|
||||
|
||||
refresh();
|
||||
|
||||
return {characters, findById};
|
||||
})
|
||||
@@ -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,95 @@
|
||||
<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 = defineModel<boolean>('open', {default: 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="flex items-center cursor-pointer border-none bg-transparent hover:bg-transparent p-0">
|
||||
<Transition
|
||||
mode="out-in"
|
||||
enter-active-class="transition-transform"
|
||||
enter-from-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,87 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch} from "vue";
|
||||
|
||||
const props = withDefaults(defineProps<{ min?: number; max?: number }>(), {
|
||||
min: 0,
|
||||
max: 100,
|
||||
});
|
||||
|
||||
const from = defineModel<number>('from', {default: 0});
|
||||
const to = defineModel<number>('to', {default: 0});
|
||||
|
||||
const container = ref<HTMLElement>();
|
||||
|
||||
const dragFill = (event: PointerEvent) => {
|
||||
const rect = container.value?.getBoundingClientRect();
|
||||
|
||||
if (!rect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const span = (props.max - props.min) || 1;
|
||||
const width = to.value - from.value;
|
||||
const startX = event.clientX;
|
||||
const startFrom = from.value;
|
||||
|
||||
const onMove = (moveEvent: PointerEvent) => {
|
||||
const delta = Math.round(((moveEvent.clientX - startX) / rect.width) * span);
|
||||
const newFrom = Math.min(Math.max(startFrom + delta, props.min), props.max - width);
|
||||
|
||||
from.value = newFrom;
|
||||
to.value = newFrom + width;
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
};
|
||||
|
||||
watch(from, value => {
|
||||
if (value > to.value) {
|
||||
to.value = value;
|
||||
}
|
||||
});
|
||||
watch(to, value => {
|
||||
if (value < from.value) {
|
||||
from.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
const fillStyle = computed(() => {
|
||||
const span = (props.max - props.min) || 1;
|
||||
|
||||
return {
|
||||
left: `${((from.value - props.min) / span) * 100}%`,
|
||||
width: `${((to.value - from.value) / span) * 100}%`,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="dual-range relative h-6 select-none">
|
||||
<div class="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-gray-700 pointer-events-none"></div>
|
||||
<div class="absolute top-1/2 h-2 -translate-y-1/2 rounded-full bg-emerald-500 cursor-grab active:cursor-grabbing"
|
||||
:style="fillStyle" @pointerdown="dragFill"></div>
|
||||
<input type="range" :min="min" :max="max" v-model.number="from"
|
||||
class="absolute inset-0 w-full h-full m-0 bg-transparent appearance-none pointer-events-none" />
|
||||
<input type="range" :min="min" :max="max" v-model.number="to"
|
||||
class="absolute inset-0 w-full h-full m-0 bg-transparent appearance-none pointer-events-none" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/style.css";
|
||||
|
||||
.dual-range input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
@apply w-4 h-4 rounded-full bg-emerald-500 border-2 border-gray-200 cursor-pointer pointer-events-auto;
|
||||
}
|
||||
|
||||
.dual-range input[type="range"]::-moz-range-thumb {
|
||||
@apply w-4 h-4 rounded-full bg-emerald-500 border-2 border-gray-200 cursor-pointer pointer-events-auto;
|
||||
}
|
||||
</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,77 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import {vOnClickOutside} from '@vueuse/components';
|
||||
import {useVirtualList} from '@vueuse/core';
|
||||
import {computed, ref, watch} from 'vue';
|
||||
|
||||
interface Props {
|
||||
items: T[];
|
||||
itemHeight?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
itemHeight: 24,
|
||||
});
|
||||
|
||||
const modelValue = defineModel<T>();
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
watch(() => props.items, items => {
|
||||
if (items.length > 0) {
|
||||
isOpen.value = true;
|
||||
}
|
||||
});
|
||||
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 class="relative" @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 w-full">
|
||||
<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 overflow-hidden"
|
||||
: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,10 @@
|
||||
export { default as ClipboardButton } from './ClipboardButton.vue';
|
||||
export { default as Dropdown } from './Dropdown.vue';
|
||||
export { default as DualRangeSlider } from './DualRangeSlider.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, { ignoredColumns: ['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;
|
||||
ignoredColumns?: 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?.ignoredColumns)?.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,59 @@
|
||||
<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, bottom, left, right } = useElementBounding(mainDiv);
|
||||
|
||||
const positions = computed(() => [
|
||||
top.value < height.value / 2 ? 'top' : 'bottom',
|
||||
left.value < width.value / 2 ? 'left' : 'right',
|
||||
]);
|
||||
|
||||
const floatingStyle = computed(() => {
|
||||
const style: Record<string, string> = {};
|
||||
if (positions.value.includes('top')) {
|
||||
style.top = `${bottom.value}px`;
|
||||
} else {
|
||||
style.bottom = `${height.value - top.value}px`;
|
||||
}
|
||||
if (positions.value.includes('left')) {
|
||||
style.left = `${left.value}px`;
|
||||
} else {
|
||||
style.right = `${width.value - right.value}px`;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
</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>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="tooltip-floating" :style="floatingStyle">
|
||||
<slot />
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/style.css";
|
||||
|
||||
.tooltip-floating {
|
||||
@apply fixed z-30;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createSharedComposable, useWindowSize } from "@vueuse/core";
|
||||
|
||||
export const useSharedWindowSize = createSharedComposable(useWindowSize);
|
||||
@@ -0,0 +1 @@
|
||||
export { useAutoRefresh } from './useAutoRefresh';
|
||||
@@ -0,0 +1,30 @@
|
||||
import {onUnmounted, ref} from "vue";
|
||||
|
||||
const DEFAULT_INTERVAL = 1_000;
|
||||
|
||||
export const useAutoRefresh = (callback: () => void | Promise<void>, interval: number = DEFAULT_INTERVAL) => {
|
||||
const active = ref(false);
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const stop = () => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
active.value = false;
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (active.value) {
|
||||
return;
|
||||
}
|
||||
active.value = true;
|
||||
timer = setInterval(callback, interval);
|
||||
};
|
||||
|
||||
const toggle = () => active.value ? stop() : start();
|
||||
|
||||
onUnmounted(stop);
|
||||
|
||||
return {active, start, stop, toggle};
|
||||
};
|
||||
@@ -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,22 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {Corporation} from "./corporations.ts";
|
||||
|
||||
interface Props {
|
||||
corporationId: number;
|
||||
corporation?: Corporation | null;
|
||||
division?: number | null;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 32
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex">
|
||||
<img class="me-2" :src="`https://images.evetech.net/corporations/${corporationId}/logo?size=${size}`" />
|
||||
<span>{{ corporation?.name ?? 'Corporation' }}<template v-if="division"> · Division {{ division }}</template></span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
import {corporationApi} from "@/mammon";
|
||||
import {defineStore} from "pinia";
|
||||
import {ref} from "vue";
|
||||
import {CorporationResponse} from "@/generated/mammon";
|
||||
|
||||
export type Corporation = CorporationResponse
|
||||
|
||||
export const useCorporationsStore = defineStore('corporations', () => {
|
||||
const corporations = ref<Record<number, Corporation | null>>({});
|
||||
const inFlight = new Map<number, Promise<Corporation | undefined>>();
|
||||
|
||||
const findById = async (corporationId: number): Promise<Corporation | undefined> => {
|
||||
const cached = corporations.value[corporationId];
|
||||
|
||||
if (cached !== undefined) {
|
||||
return cached ?? undefined;
|
||||
}
|
||||
|
||||
let request = inFlight.get(corporationId);
|
||||
|
||||
if (!request) {
|
||||
request = corporationApi.findCorporation(corporationId)
|
||||
.then(response => {
|
||||
corporations.value[corporationId] = response.data;
|
||||
return response.data;
|
||||
})
|
||||
.catch(() => {
|
||||
corporations.value[corporationId] = null;
|
||||
return undefined;
|
||||
})
|
||||
.finally(() => inFlight.delete(corporationId));
|
||||
inFlight.set(corporationId, request);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
return {corporations, findById};
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './corporations.ts'
|
||||
|
||||
export {default as CorporationLabel} from './CorporationLabel.vue';
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
+11
-1
@@ -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,142 @@
|
||||
<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";
|
||||
import {toast} from "@/toast";
|
||||
|
||||
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) {
|
||||
return ledgersStore.createMain({name: name.value})
|
||||
}
|
||||
return ledgersStore.createCombined({name: name.value, memberLedgerIds: members.value.map(l => l.ledgerId)})
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
if (type.value === LedgerTypes.Main) {
|
||||
return ledgersStore.updateMain(props.ledgerId, {name: name.value})
|
||||
}
|
||||
return ledgersStore.updateCombined(props.ledgerId, {name: name.value, memberLedgerIds: members.value.map(l => l.ledgerId)})
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
if (!canSave.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const creating = isCreating.value;
|
||||
const action = creating ? create() : update();
|
||||
|
||||
modalOpen.value = false;
|
||||
action.then(() => toast.success(creating ? 'Ledger created' : 'Ledger updated'));
|
||||
}
|
||||
|
||||
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,34 @@
|
||||
import {describe, expect, test} from 'vitest';
|
||||
import {appraiseItemBalances} from './balanceAppraisal';
|
||||
import type {MarketTypePrice} from '@/market';
|
||||
|
||||
const price = (id: number, buy: number, sell: number): MarketTypePrice => ({
|
||||
type: {id, name: `type-${id}`} as MarketTypePrice['type'],
|
||||
buy,
|
||||
sell,
|
||||
orderCount: 1,
|
||||
});
|
||||
|
||||
describe('appraiseItemBalances', () => {
|
||||
test('sums quantity times buy and sell across stacks', () => {
|
||||
const result = appraiseItemBalances(
|
||||
[{typeId: 1, quantity: 2}, {typeId: 2, quantity: 3}],
|
||||
[price(1, 10, 12), price(2, 100, 110)],
|
||||
);
|
||||
|
||||
expect(result).toEqual({buy: 2 * 10 + 3 * 100, sell: 2 * 12 + 3 * 110});
|
||||
});
|
||||
|
||||
test('ignores stacks with no resolved price', () => {
|
||||
const result = appraiseItemBalances(
|
||||
[{typeId: 1, quantity: 2}, {typeId: 99, quantity: 5}],
|
||||
[price(1, 10, 12)],
|
||||
);
|
||||
|
||||
expect(result).toEqual({buy: 20, sell: 24});
|
||||
});
|
||||
|
||||
test('returns zero totals for no items', () => {
|
||||
expect(appraiseItemBalances([], [])).toEqual({buy: 0, sell: 0});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import {ItemBalanceResponse} from "@/generated/mammon";
|
||||
import {MarketTypePrice} from "@/market";
|
||||
|
||||
export interface ItemBalanceAppraisal {
|
||||
buy: number;
|
||||
sell: number;
|
||||
}
|
||||
|
||||
export const appraiseItemBalances = (
|
||||
itemBalances: ItemBalanceResponse[],
|
||||
prices: MarketTypePrice[],
|
||||
): ItemBalanceAppraisal => {
|
||||
const priceByTypeId = new Map(prices.map(p => [p.type.id, p]));
|
||||
|
||||
return itemBalances.reduce<ItemBalanceAppraisal>((totals, {typeId, quantity}) => {
|
||||
const price = priceByTypeId.get(typeId);
|
||||
|
||||
if (price) {
|
||||
totals.buy += quantity * price.buy;
|
||||
totals.sell += quantity * price.sell;
|
||||
}
|
||||
|
||||
return totals;
|
||||
}, {buy: 0, sell: 0});
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './ledger';
|
||||
export * from './balanceAppraisal';
|
||||
|
||||
export {default as LedgerLabel} from './LedgerLabel.vue';
|
||||
export {default as LedgerSelect} from './LedgerSelect.vue';
|
||||
export {default as EditLedgerModal} from './EditLedgerModal.vue';
|
||||
@@ -0,0 +1,91 @@
|
||||
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";
|
||||
import {onActivitiesProcessed} from "@/activity";
|
||||
|
||||
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();
|
||||
|
||||
onActivitiesProcessed(refresh);
|
||||
|
||||
return {ledgers, findById, findAllById, createMain, createCombined, updateMain, updateCombined};
|
||||
})
|
||||
|
||||
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:'});
|
||||
}
|
||||
+22
-4
@@ -1,15 +1,33 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import {createPinia} from 'pinia';
|
||||
import {createApp} from 'vue';
|
||||
import {createRouter, createWebHistory} from 'vue-router';
|
||||
import App from './App.vue';
|
||||
import { routes } from './routes';
|
||||
import {useAuthStore} from './auth';
|
||||
import {initLogger} from './logger';
|
||||
import {routeNames, routes} from './routes';
|
||||
import './style.css';
|
||||
|
||||
initLogger();
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(pinia);
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
await authStore.bootstrap();
|
||||
|
||||
router.beforeEach(to => {
|
||||
if (!to.meta.public && !authStore.isAuthenticated) {
|
||||
return {name: routeNames.home};
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
@@ -0,0 +1 @@
|
||||
export * from './mammonService'
|
||||
@@ -0,0 +1,119 @@
|
||||
import {logResource} from "@/service";
|
||||
import {getAccessToken, setAccessToken} from "@/auth/token";
|
||||
import {toast} from "@/toast";
|
||||
import axios, {InternalAxiosRequestConfig} from "axios";
|
||||
import {
|
||||
AcquisitionApi,
|
||||
ActivityApi,
|
||||
AuthApi,
|
||||
CharacterApi,
|
||||
CorporationApi,
|
||||
LedgerApi,
|
||||
LocationApi,
|
||||
MarketApi,
|
||||
MeResponse,
|
||||
ReprocessingApi,
|
||||
RuleBookApi,
|
||||
RuleScriptApi,
|
||||
TransactionApi
|
||||
} from "@/generated/mammon";
|
||||
|
||||
export const mammonUrl = import.meta.env.VITE_MAMMON_URL;
|
||||
export const mammonLoginUrl = mammonUrl + "oauth2/authorization/esi"
|
||||
|
||||
const mammonAxiosInstance = axios.create({
|
||||
baseURL: mammonUrl,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
logResource(mammonAxiosInstance)
|
||||
|
||||
const credentialedClient = axios.create({
|
||||
baseURL: mammonUrl,
|
||||
withCredentials: true,
|
||||
})
|
||||
logResource(credentialedClient)
|
||||
|
||||
mammonAxiosInstance.interceptors.request.use(config => {
|
||||
const token = getAccessToken();
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
})
|
||||
|
||||
const authApi = new AuthApi(undefined, mammonUrl, mammonAxiosInstance);
|
||||
const credentialedAuthApi = new AuthApi(undefined, mammonUrl, credentialedClient);
|
||||
|
||||
let onAuthExpired: () => void = () => {};
|
||||
|
||||
export const setOnAuthExpired = (callback: () => void): void => {
|
||||
onAuthExpired = callback;
|
||||
}
|
||||
|
||||
let refreshing: Promise<string | null> | null = null;
|
||||
|
||||
export const refreshAccessToken = (): Promise<string | null> => {
|
||||
if (!refreshing) {
|
||||
refreshing = credentialedAuthApi.refresh()
|
||||
.then(response => {
|
||||
setAccessToken(response.data.accessToken);
|
||||
return response.data.accessToken;
|
||||
})
|
||||
.catch(() => {
|
||||
setAccessToken(null);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshing = null;
|
||||
});
|
||||
}
|
||||
return refreshing;
|
||||
}
|
||||
|
||||
mammonAxiosInstance.interceptors.response.use(response => response, async error => {
|
||||
const original = error.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined;
|
||||
|
||||
if (error.response?.status === 401 && original && !original._retried) {
|
||||
original._retried = true;
|
||||
|
||||
const token = await refreshAccessToken();
|
||||
|
||||
if (token) {
|
||||
original.headers.Authorization = `Bearer ${token}`;
|
||||
return mammonAxiosInstance(original);
|
||||
}
|
||||
onAuthExpired();
|
||||
}
|
||||
|
||||
const status = error.response?.status;
|
||||
|
||||
if (status !== 401 && status !== 403) {
|
||||
toast.error('Something went wrong');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
})
|
||||
|
||||
export const fetchMe = (): Promise<MeResponse> =>
|
||||
authApi.me().then(response => response.data);
|
||||
|
||||
export const addCharacter = (): Promise<void> =>
|
||||
authApi.addCharacter({withCredentials: true}).then(() => undefined);
|
||||
|
||||
export const postLogout = (): Promise<void> =>
|
||||
credentialedAuthApi.logout().then(() => undefined);
|
||||
|
||||
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 corporationApi = new CorporationApi(undefined, mammonUrl, mammonAxiosInstance);
|
||||
export const ruleBookApi = new RuleBookApi(undefined, mammonUrl, mammonAxiosInstance);
|
||||
export const ruleScriptApi = new RuleScriptApi(undefined, mammonUrl, mammonAxiosInstance);
|
||||
export const activityApi = new ActivityApi(undefined, mammonUrl, mammonAxiosInstance);
|
||||
export const acquisitionApi = new AcquisitionApi(undefined, mammonUrl, mammonAxiosInstance);
|
||||
export const marketApi = new MarketApi(undefined, mammonUrl, mammonAxiosInstance);
|
||||
export const reprocessingApi = new ReprocessingApi(undefined, mammonUrl, mammonAxiosInstance);
|
||||
export const locationApi = new LocationApi(undefined, mammonUrl, mammonAxiosInstance);
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import {formatIsk} from "@/formaters";
|
||||
import {computed} from "vue";
|
||||
|
||||
interface Props {
|
||||
amount: number;
|
||||
colored?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
colored: true,
|
||||
});
|
||||
|
||||
const computedClass = computed(() => {
|
||||
if (!props.colored) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return props.amount >= 0 ? 'text-emerald-400' : 'text-amber-700';
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="computedClass">{{ formatIsk(amount) }}</span>
|
||||
</template>
|
||||
@@ -1,13 +0,0 @@
|
||||
import { esiAxiosInstance } from "@/service";
|
||||
|
||||
|
||||
export type MarketOrderHistory = {
|
||||
average: number;
|
||||
date: string;
|
||||
highest: number;
|
||||
lowest: number;
|
||||
order_count: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export const getHistory = async (regionId: number, tyeId: number): Promise<MarketOrderHistory[]> => (await esiAxiosInstance.get(`/markets/${regionId}/history/`, { params: { type_id: tyeId } })).data;
|
||||
@@ -0,0 +1,13 @@
|
||||
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())
|
||||
|
||||
export const toEveDatetimeInput = (date: Date) => date.toISOString().slice(0, 16)
|
||||
export const fromEveDatetimeInput = (value: string) => new Date(`${value}Z`)
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import {LoadingSpinner, Tooltip} from '@/components';
|
||||
import {formatIsk} from '@/formaters';
|
||||
import {getQuartiles, type HistoryQuartiles, MarketTrendIcon} from '@/market';
|
||||
import {computedAsync} from '@vueuse/core';
|
||||
import {computed, ref} from 'vue';
|
||||
|
||||
interface Props {
|
||||
id: number;
|
||||
buy: number;
|
||||
sell: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const open = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
const quartiles = computedAsync<HistoryQuartiles | null>(
|
||||
() => props.id ? getQuartiles(props.id) : null,
|
||||
null,
|
||||
loading,
|
||||
);
|
||||
|
||||
const lineColor = computed(() => {
|
||||
if (!quartiles.value) {
|
||||
return '';
|
||||
}
|
||||
if (props.buy >= quartiles.value.q3) {
|
||||
return 'line-blue';
|
||||
}
|
||||
if (props.sell >= quartiles.value.q3) {
|
||||
return 'line-green';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip v-model:open="open" class="tooltip">
|
||||
<template #header>
|
||||
<LoadingSpinner v-if="loading || !quartiles" />
|
||||
<MarketTrendIcon v-else :trend="quartiles.trend" />
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="bg-slate-500 tooltip-content" v-if="quartiles">
|
||||
<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(quartiles.q1) }}</td>
|
||||
<td class="text-right text-nowrap">{{ formatIsk(quartiles.median) }}</td>
|
||||
<td class="text-right text-nowrap">{{ formatIsk(quartiles.q3) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/style.css";
|
||||
|
||||
.tooltip {
|
||||
@apply ms-auto;
|
||||
&.open {
|
||||
&.tooltip-top>:deep(div.header) {
|
||||
@apply rounded-t-md bg-slate-600;
|
||||
}
|
||||
&.tooltip-bottom>:deep(div.header) {
|
||||
@apply rounded-b-md bg-slate-600;
|
||||
}
|
||||
}
|
||||
}
|
||||
.tooltip>:deep(div.header) {
|
||||
@apply btn-icon px-2;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,287 @@
|
||||
<script setup lang="ts">
|
||||
import {SliderCheckbox} from '@/components';
|
||||
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
|
||||
import {getListedSellTypeIds, MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore} from "@/market";
|
||||
import {MinusIcon, PlusIcon} from '@heroicons/vue/24/outline';
|
||||
import {useStorage} from '@vueuse/core';
|
||||
import {computed, ref, watch} from 'vue';
|
||||
import {AcquiredType} from './AcquiredType';
|
||||
import AcquisitionQuartilesTooltip from './AcquisitionQuartilesTooltip.vue';
|
||||
import {formatEveDate, formatIsk, percentFormater} from "@/formaters.ts";
|
||||
import {Ledger, LedgerLabel, useLedgersStore} from "@/ledger";
|
||||
|
||||
type Result = {
|
||||
id: string;
|
||||
type: MarketType;
|
||||
name: string;
|
||||
buy: number;
|
||||
sell: number;
|
||||
price: number;
|
||||
remaining: number;
|
||||
quantity: number;
|
||||
precentProfit: number;
|
||||
iskProfit: number;
|
||||
date: Date;
|
||||
ledger?: Ledger;
|
||||
ledgerName: string;
|
||||
acquisitions: AcquiredType[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items?: AcquiredType[];
|
||||
infoOnly?: boolean;
|
||||
showAll?: boolean;
|
||||
ignoredColumns?: 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,
|
||||
ignoredColumns: () => [],
|
||||
defaultSortKey: 'precentProfit',
|
||||
});
|
||||
defineEmits<Emits>();
|
||||
|
||||
const columnsToIgnore = computed(() => {
|
||||
const ignoredColumns = typeof props.ignoredColumns === 'string' ? [props.ignoredColumns] : [...props.ignoredColumns];
|
||||
|
||||
if (props.infoOnly && !ignoredColumns.includes('buttons')) {
|
||||
ignoredColumns.push('buttons');
|
||||
}
|
||||
if (!props.showAll && !ignoredColumns.includes('ledger')) {
|
||||
ignoredColumns.push('ledger');
|
||||
}
|
||||
if (ignoredColumns.includes('ledger')) {
|
||||
ignoredColumns.push('ledgerName');
|
||||
}
|
||||
return ignoredColumns;
|
||||
});
|
||||
|
||||
const marketTaxStore = useMarketTaxStore();
|
||||
const ledgersStore = useLedgersStore();
|
||||
|
||||
const listedTypeIds = ref(new Set<number>());
|
||||
watch(() => props.items, async () => {
|
||||
listedTypeIds.value = await getListedSellTypeIds();
|
||||
}, { immediate: true });
|
||||
|
||||
const threshold = useStorage('market-acquisition-threshold', 10);
|
||||
const filter = ref("");
|
||||
const unlistedOnly = ref(false);
|
||||
const matchesFilter = (r: AcquiredType) =>
|
||||
r.type.name.toLowerCase().includes(filter.value.toLowerCase())
|
||||
&& (!unlistedOnly.value || !listedTypeIds.value.has(r.type.id));
|
||||
const results = computed<Result[]>(() => {
|
||||
const filteredItems = props.items.filter(matchesFilter);
|
||||
|
||||
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,
|
||||
ledger: ledgersStore.findById(r.ledgerId),
|
||||
ledgerName: ledgersStore.findById(r.ledgerId)?.name ?? '',
|
||||
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,
|
||||
ledgerName: '',
|
||||
acquisitions: group
|
||||
});
|
||||
});
|
||||
return list;
|
||||
});
|
||||
const { sortedArray, headerProps, showColumn } = useSort<Result>(results, {
|
||||
defaultSortKey: props.defaultSortKey,
|
||||
defaultSortDirection: 'desc',
|
||||
ignoredColumns: 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 flex items-center" title="Show only acquisitions with no open sell order">
|
||||
<SliderCheckbox class="me-1" v-model="unlistedOnly" /> Unlisted only
|
||||
</div>
|
||||
<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="ledgerName">Ledger</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" />
|
||||
<AcquisitionQuartilesTooltip :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('ledger')">
|
||||
<LedgerLabel v-if="r.data.ledger" :ledger="r.data.ledger" link />
|
||||
</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('ledger')">
|
||||
<template v-if="!showColumn('name') && !showColumn('buy') && !showColumn('sell')">Total</template>
|
||||
</td>
|
||||
<td v-if="showColumn('date')">
|
||||
<template v-if="!showColumn('name') && !showColumn('buy') && !showColumn('sell') && !showColumn('ledger')">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,70 @@
|
||||
<script setup lang="ts">
|
||||
import {getMarketTypes, getPrices, MarketTypePrice} from "@/market";
|
||||
import {ref, watch} from 'vue';
|
||||
import {ArrowPathIcon} from '@heroicons/vue/24/outline';
|
||||
import {useAutoRefresh} from '@/composables';
|
||||
import {MarketLocationInput, useMarketLocationStore} from '@/market/location';
|
||||
import {AcquiredType} from './AcquiredType';
|
||||
import {RawAcquiredType} from './acquisition';
|
||||
import AcquisitionResultTable from './AcquisitionResultTable.vue';
|
||||
import BuyModal from './BuyModal.vue';
|
||||
import SellModal from './SellModal.vue';
|
||||
|
||||
interface Props {
|
||||
items: RawAcquiredType[];
|
||||
ignoredColumns?: string[] | string;
|
||||
ledgerId?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const buyModal = ref<typeof BuyModal>();
|
||||
const sellModal = ref<typeof SellModal>();
|
||||
|
||||
const marketLocationStore = useMarketLocationStore();
|
||||
const enriched = ref<AcquiredType[]>([]);
|
||||
|
||||
const refresh = async (itms: RawAcquiredType[] = props.items) => {
|
||||
if (itms.length === 0) {
|
||||
enriched.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const types = await getMarketTypes([...new Set(itms.map(i => i.type))]);
|
||||
const prices = await getPrices(types, marketLocationStore.location.id);
|
||||
|
||||
enriched.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
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
watch([() => props.items, () => marketLocationStore.location], ([itms]) => refresh(itms), {immediate: true});
|
||||
|
||||
const {active: autoRefresh, toggle: toggleAutoRefresh} = useAutoRefresh(() => refresh(), 5 * 60 * 1000);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex">
|
||||
<div class="ms-auto flex items-center gap-2">
|
||||
<MarketLocationInput v-model="marketLocationStore.location" />
|
||||
<div class="flex">
|
||||
<button class="rounded-e-none" @click="refresh()">Refresh</button>
|
||||
<button class="rounded-s-none border-s-0 flex items-center" :class="{ 'bg-slate-700': autoRefresh }" title="Auto refresh" @click="toggleAutoRefresh">
|
||||
<ArrowPathIcon class="w-5 h-5" :class="{ 'animate-spin': autoRefresh }" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="enriched.length > 0">
|
||||
<AcquisitionResultTable :items="enriched" :ignoredColumns="ignoredColumns" @buy="(types, price, buy, sell) => buyModal?.open(types[0].type, { 'Price': price, 'Buy': buy, 'Sell': sell }, props.ledgerId)" @sell="types => sellModal?.open(types, props.ledgerId)" />
|
||||
<BuyModal ref="buyModal" />
|
||||
<SellModal ref="sellModal" />
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import {Modal} from '@/components';
|
||||
import {formatIsk} from '@/formaters';
|
||||
import {MarketType, MarketTypeLabel} from '@/market';
|
||||
import {Ledger, LedgerSelect, isMain, useLedgersStore} from '@/ledger';
|
||||
import {computed, ref} from 'vue';
|
||||
import {fromEveDatetimeInput, toEveDatetimeInput} from './AcquiredType';
|
||||
import {useAcquiredTypesStore} from './acquisition';
|
||||
import {toast} from '@/toast';
|
||||
|
||||
const acquiredTypesStore = useAcquiredTypesStore();
|
||||
const ledgersStore = useLedgersStore();
|
||||
|
||||
const mainLedgers = computed(() => ledgersStore.ledgers.filter(isMain));
|
||||
|
||||
const modalOpen = ref<boolean>(false);
|
||||
const type = ref<MarketType>();
|
||||
const suggestions = ref<Record<string, number>>({});
|
||||
const price = ref(1000000);
|
||||
const count = ref(1);
|
||||
const date = ref('');
|
||||
const contextLedgerId = ref<string>();
|
||||
const ledger = ref<Ledger>();
|
||||
|
||||
const ledgerId = computed(() => contextLedgerId.value ?? ledger.value?.ledgerId);
|
||||
|
||||
const open = (t: MarketType, s?: Record<string, number> | number, ledgerId?: string) => {
|
||||
type.value = t;
|
||||
count.value = 1;
|
||||
date.value = toEveDatetimeInput(new Date());
|
||||
contextLedgerId.value = ledgerId;
|
||||
ledger.value = undefined;
|
||||
|
||||
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 = async () => {
|
||||
const id = type.value?.id;
|
||||
|
||||
if (!id) {
|
||||
modalOpen.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const lid = ledgerId.value;
|
||||
|
||||
if (!lid) {
|
||||
return;
|
||||
}
|
||||
|
||||
await acquiredTypesStore.addAcquiredType(id, count.value, price.value, lid, fromEveDatetimeInput(date.value));
|
||||
modalOpen.value = false;
|
||||
toast.success('Acquisition added');
|
||||
}
|
||||
|
||||
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>
|
||||
<div class="flex me-2 mb-auto">
|
||||
<span>Eve Time: </span>
|
||||
<input class="ms-2" type="datetime-local" v-model="date" />
|
||||
</div>
|
||||
<div v-if="!contextLedgerId" class="flex me-2 mb-auto">
|
||||
<span>Ledger: </span>
|
||||
<LedgerSelect class="ms-2" v-model="ledger" :ledgers="mainLedgers" />
|
||||
</div>
|
||||
<button class="mb-auto" :disabled="!ledgerId" @click="add">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { Modal } from '@/components';
|
||||
import { MarketType, MarketTypeLabel } from '@/market';
|
||||
import { Ledger, LedgerSelect, isMain, useLedgersStore } from '@/ledger';
|
||||
import { computed, ref } from 'vue';
|
||||
import { AcquiredType, acquiredTypesToSorted, fromEveDatetimeInput, toEveDatetimeInput } from './AcquiredType';
|
||||
import { useAcquiredTypesStore } from './acquisition';
|
||||
import { toast } from '@/toast';
|
||||
|
||||
|
||||
const acquiredTypesStore = useAcquiredTypesStore();
|
||||
const ledgersStore = useLedgersStore();
|
||||
|
||||
const mainLedgers = computed(() => ledgersStore.ledgers.filter(isMain));
|
||||
|
||||
const modalOpen = ref<boolean>(false);
|
||||
const type = ref<MarketType>();
|
||||
const count = ref(1);
|
||||
const date = ref('');
|
||||
const types = ref<AcquiredType[]>([]);
|
||||
const contextLedgerId = ref<string>();
|
||||
const ledger = ref<Ledger>();
|
||||
|
||||
const ledgerId = computed(() => contextLedgerId.value ?? ledger.value?.ledgerId);
|
||||
|
||||
const open = (t: AcquiredType[], ledgerId?: string) => {
|
||||
if (t.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
types.value = acquiredTypesToSorted(t);
|
||||
type.value = t[0].type;
|
||||
count.value = 1;
|
||||
date.value = toEveDatetimeInput(new Date());
|
||||
contextLedgerId.value = ledgerId;
|
||||
ledger.value = undefined;
|
||||
modalOpen.value = true;
|
||||
}
|
||||
const remove = async () => {
|
||||
if (types.value.length === 0) {
|
||||
modalOpen.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const lid = ledgerId.value;
|
||||
|
||||
if (!lid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const datetime = fromEveDatetimeInput(date.value);
|
||||
let remaining = count.value;
|
||||
|
||||
for (const t of types.value) {
|
||||
if (remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const quantity = Math.min(remaining, t.remaining);
|
||||
|
||||
await acquiredTypesStore.removeAcquiredType(t.id, quantity, lid, datetime);
|
||||
remaining -= quantity;
|
||||
}
|
||||
modalOpen.value = false;
|
||||
toast.success('Consumption added');
|
||||
}
|
||||
|
||||
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>
|
||||
<div class="flex me-2 mb-auto">
|
||||
<span>Eve Time: </span>
|
||||
<input class="ms-2" type="datetime-local" v-model="date" />
|
||||
</div>
|
||||
<div v-if="!contextLedgerId" class="flex me-2 mb-auto">
|
||||
<span>Ledger: </span>
|
||||
<LedgerSelect class="ms-2" v-model="ledger" :ledgers="mainLedgers" />
|
||||
</div>
|
||||
<button class="mb-auto" :disabled="!ledgerId" @click="remove">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,71 @@
|
||||
import {defineStore} from "pinia";
|
||||
import {computed, ref} from "vue";
|
||||
import {acquisitionApi, activityApi} from "@/mammon";
|
||||
import {AcquisitionResponse, ActivitySourceResponse} from "@/generated/mammon";
|
||||
import {onActivitiesProcessed} from "@/activity";
|
||||
|
||||
export type RawAcquiredType = {
|
||||
id: string;
|
||||
ledgerId: string;
|
||||
type: number;
|
||||
quantity: number;
|
||||
remaining: number;
|
||||
price: number;
|
||||
date: Date;
|
||||
source: ActivitySourceResponse;
|
||||
}
|
||||
|
||||
export const toAcquiredType = (a: AcquisitionResponse): RawAcquiredType => ({
|
||||
id: a.acquisitionId,
|
||||
ledgerId: a.ledgerId,
|
||||
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));
|
||||
|
||||
const addAcquiredType = (type: number, quantity: number, price: number, ledgerId: string, datetime: Date) =>
|
||||
activityApi.acquire({
|
||||
source: { type: 'LEDGER', characterId: null, ledgerId },
|
||||
marketTypeId: type,
|
||||
quantity,
|
||||
unitPrice: price,
|
||||
datetime: datetime.toISOString(),
|
||||
taxes: null,
|
||||
description: null,
|
||||
});
|
||||
|
||||
const removeAcquiredType = (id: string, quantity: number, ledgerId: string, datetime: Date) => {
|
||||
const acquisition = acquiredTypes.value.find(a => a.id === id);
|
||||
|
||||
if (!acquisition) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return activityApi.consume({
|
||||
source: { type: 'LEDGER', characterId: null, ledgerId },
|
||||
marketTypeId: acquisition.type,
|
||||
quantity,
|
||||
unitPrice: 0,
|
||||
datetime: datetime.toISOString(),
|
||||
taxes: null,
|
||||
description: null,
|
||||
});
|
||||
};
|
||||
|
||||
const refresh = () => acquisitionApi.findAllAcquisitions()
|
||||
.then(response => acquiredTypes.value = response.data.map(toAcquiredType));
|
||||
|
||||
refresh();
|
||||
|
||||
onActivitiesProcessed(refresh);
|
||||
|
||||
return { acquiredTypes: types, addAcquiredType, removeAcquiredType };
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './AcquiredType';
|
||||
export * from './acquisition';
|
||||
|
||||
export { default as AcquisitionResultTable } from './AcquisitionResultTable.vue';
|
||||
export { default as AcquisitionsPanel } from './AcquisitionsPanel.vue';
|
||||
export { default as BuyModal } from './BuyModal.vue';
|
||||
export { default as SellModal } from './SellModal.vue';
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
|
||||
import {MarketTypeLabel} from '@/market/type';
|
||||
import {computed} from 'vue';
|
||||
import IskLabel from '../IskLabel.vue';
|
||||
import {AppraisalItemValue} from './appraise';
|
||||
|
||||
interface Props {
|
||||
result?: AppraisalItemValue[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
result: () => []
|
||||
});
|
||||
|
||||
const { sortedArray, headerProps } = useSort(computed(() => props.result), {
|
||||
defaultSortKey: 'sell',
|
||||
defaultSortDirection: 'desc'
|
||||
});
|
||||
|
||||
const totalBuy = computed(() => props.result.reduce((sum, r) => sum + r.buy, 0));
|
||||
const totalSell = computed(() => props.result.reduce((sum, r) => sum + r.sell, 0));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VirtualScrollTable :list="sortedArray" :itemHeight="33" :footerHeight="33" bottom="1rem">
|
||||
<template #default="{ list }">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
|
||||
<SortableHeader v-bind="headerProps" sortKey="quantity">Qty</SortableHeader>
|
||||
<SortableHeader v-bind="headerProps" sortKey="buyUnit">Buy/unit</SortableHeader>
|
||||
<SortableHeader v-bind="headerProps" sortKey="sellUnit">Sell/unit</SortableHeader>
|
||||
<SortableHeader v-bind="headerProps" sortKey="buy">Buy total</SortableHeader>
|
||||
<SortableHeader v-bind="headerProps" sortKey="sell">Sell total</SortableHeader>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in list" :key="r.data.typeID">
|
||||
<td>
|
||||
<MarketTypeLabel :id="r.data.typeID" :name="r.data.name" />
|
||||
</td>
|
||||
<td class="text-right">{{ r.data.quantity.toLocaleString() }}</td>
|
||||
<td class="text-right"><IskLabel :amount="r.data.buyUnit" :colored="false" /></td>
|
||||
<td class="text-right"><IskLabel :amount="r.data.sellUnit" :colored="false" /></td>
|
||||
<td class="text-right"><IskLabel :amount="r.data.buy" :colored="false" /></td>
|
||||
<td class="text-right"><IskLabel :amount="r.data.sell" :colored="false" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="4" class="font-semibold">Total</td>
|
||||
<td class="text-right"><IskLabel :amount="totalBuy" :colored="false" /></td>
|
||||
<td class="text-right"><IskLabel :amount="totalSell" :colored="false" /></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div class="text-center mt-4">
|
||||
<span>No items found</span>
|
||||
</div>
|
||||
</template>
|
||||
</VirtualScrollTable>
|
||||
</template>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
|
||||
import {MarketTypeLabel} from '@/market/type';
|
||||
import {computed} from 'vue';
|
||||
import {MarketLocation} from '../location';
|
||||
import IskLabel from '../IskLabel.vue';
|
||||
import {ComparedItem} from './compare';
|
||||
|
||||
interface Props {
|
||||
result?: ComparedItem[];
|
||||
locations?: MarketLocation[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
result: () => [],
|
||||
locations: () => []
|
||||
});
|
||||
|
||||
const buyKey = (id: number): `buy:${number}` => `buy:${id}`;
|
||||
const sellKey = (id: number): `sell:${number}` => `sell:${id}`;
|
||||
|
||||
type SortableItem = ComparedItem & Record<`buy:${number}` | `sell:${number}`, number>;
|
||||
|
||||
const rows = computed<SortableItem[]>(() => props.result.map(item => {
|
||||
const sortable = {...item} as SortableItem;
|
||||
|
||||
props.locations.forEach(l => {
|
||||
const price = item.prices[l.id];
|
||||
sortable[buyKey(l.id)] = price?.buy ?? 0;
|
||||
sortable[sellKey(l.id)] = price?.sell ?? 0;
|
||||
});
|
||||
|
||||
return sortable;
|
||||
}));
|
||||
|
||||
const {sortedArray, headerProps} = useSort(rows, {
|
||||
defaultSortKey: 'name',
|
||||
defaultSortDirection: 'asc'
|
||||
});
|
||||
|
||||
const bestBuy = (row: ComparedItem): number | undefined => {
|
||||
let best: number | undefined;
|
||||
let bestValue = -Infinity;
|
||||
props.locations.forEach(l => {
|
||||
const cell = row.prices[l.id];
|
||||
if (cell && cell.buy > bestValue) {
|
||||
bestValue = cell.buy;
|
||||
best = l.id;
|
||||
}
|
||||
});
|
||||
return bestValue > 0 ? best : undefined;
|
||||
};
|
||||
|
||||
const bestSell = (row: ComparedItem): number | undefined => {
|
||||
let best: number | undefined;
|
||||
let bestValue = -Infinity;
|
||||
props.locations.forEach(l => {
|
||||
const cell = row.prices[l.id];
|
||||
if (cell && cell.sell > bestValue) {
|
||||
bestValue = cell.sell;
|
||||
best = l.id;
|
||||
}
|
||||
});
|
||||
return bestValue > 0 ? best : undefined;
|
||||
};
|
||||
|
||||
const totals = computed(() => new Map(props.locations.map(l => [l.id, props.result.reduce((acc, row) => {
|
||||
const cell = row.prices[l.id];
|
||||
return {
|
||||
buy: acc.buy + (cell?.buy ?? 0),
|
||||
sell: acc.sell + (cell?.sell ?? 0),
|
||||
};
|
||||
}, {buy: 0, sell: 0})])));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VirtualScrollTable :list="sortedArray" :itemHeight="33" :headerHeight="66" :footerHeight="33" bottom="1rem">
|
||||
<template #default="{ list }">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader v-bind="headerProps" sortKey="name" rowspan="2">Item</SortableHeader>
|
||||
<SortableHeader v-bind="headerProps" sortKey="quantity" rowspan="2">Qty</SortableHeader>
|
||||
<th v-for="location in locations" :key="location.id" colspan="2" class="text-center border-l border-slate-500" :title="location.name">
|
||||
{{ location.systemName }}
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<template v-for="location in locations" :key="location.id">
|
||||
<SortableHeader v-bind="headerProps" :sortKey="buyKey(location.id)" class="border-l border-slate-500">Buy</SortableHeader>
|
||||
<SortableHeader v-bind="headerProps" :sortKey="sellKey(location.id)">Sell</SortableHeader>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in list" :key="r.data.typeId">
|
||||
<td>
|
||||
<MarketTypeLabel :id="r.data.typeId" :name="r.data.name" />
|
||||
</td>
|
||||
<td class="text-right">{{ r.data.quantity.toLocaleString() }}</td>
|
||||
<template v-for="location in locations" :key="location.id">
|
||||
<td class="text-right border-l border-slate-500" :class="r.data.prices[location.id] ? (bestBuy(r.data) === location.id ? 'bg-emerald-500' : 'bg-amber-900') : ''">
|
||||
<IskLabel v-if="r.data.prices[location.id]" :amount="r.data.prices[location.id].buy" :colored="false" />
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="text-right" :class="r.data.prices[location.id] ? (bestSell(r.data) === location.id ? 'bg-emerald-500' : 'bg-amber-900') : ''">
|
||||
<IskLabel v-if="r.data.prices[location.id]" :amount="r.data.prices[location.id].sell" :colored="false" />
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="2" class="font-semibold">Total</td>
|
||||
<template v-for="location in locations" :key="location.id">
|
||||
<td class="text-right border-l border-slate-500"><IskLabel :amount="totals.get(location.id)?.buy ?? 0" :colored="false" /></td>
|
||||
<td class="text-right"><IskLabel :amount="totals.get(location.id)?.sell ?? 0" :colored="false" /></td>
|
||||
</template>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div class="text-center mt-4">
|
||||
<span>No items found</span>
|
||||
</div>
|
||||
</template>
|
||||
</VirtualScrollTable>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MarketType } from "../type";
|
||||
|
||||
|
||||
export type MarketTypePrice = {
|
||||
type: MarketType;
|
||||
buy: number;
|
||||
sell: number;
|
||||
orderCount: number;
|
||||
};
|
||||
|
||||
export type PriceGetter = (types: MarketType[], locationId?: number) => Promise<MarketTypePrice[]>;
|
||||
@@ -0,0 +1,17 @@
|
||||
import {MarketType} from "../type";
|
||||
import {MarketTypePrice} from './MarketTypePrice';
|
||||
import {getMammonPrices} from './mammon';
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
export const getPrice = async (type: MarketType, locationId?: number): Promise<MarketTypePrice> => (await getPrices([type], locationId))[0];
|
||||
|
||||
export const getPrices = async (types: MarketType[], locationId?: number): Promise<MarketTypePrice[]> => {
|
||||
const batches: Promise<MarketTypePrice[]>[] = [];
|
||||
|
||||
for (let i = 0; i < types.length; i += BATCH_SIZE) {
|
||||
batches.push(getMammonPrices(types.slice(i, i + BATCH_SIZE), locationId));
|
||||
}
|
||||
|
||||
return (await Promise.all(batches)).flat();
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import {marketApi} from '@/mammon';
|
||||
import {getMarketTypes} from '../type';
|
||||
|
||||
export type AppraisalItemValue = {
|
||||
typeID: number;
|
||||
name: string;
|
||||
quantity: number;
|
||||
buy: number;
|
||||
sell: number;
|
||||
buyUnit: number;
|
||||
sellUnit: number;
|
||||
};
|
||||
|
||||
export const appraise = async (raw?: string, locationId?: number): Promise<AppraisalItemValue[]> => {
|
||||
if (!raw?.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = await marketApi.pastePrices(raw, locationId).then(r => r.data);
|
||||
const names = await getMarketTypes(results.map(r => r.marketTypeId))
|
||||
.then(types => new Map(types.map(t => [t.id, t.name])));
|
||||
|
||||
return results.map(r => ({
|
||||
typeID: r.marketTypeId,
|
||||
name: names.get(r.marketTypeId) ?? '',
|
||||
quantity: r.quantity,
|
||||
buy: r.buy,
|
||||
sell: r.sell,
|
||||
buyUnit: r.quantity ? r.buy / r.quantity : 0,
|
||||
sellUnit: r.quantity ? r.sell / r.quantity : 0,
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import {marketApi} from '@/mammon';
|
||||
import {getMarketTypes, MarketType} from '../type';
|
||||
import {MarketTypePrice} from './MarketTypePrice';
|
||||
|
||||
export type LocationPrice = {
|
||||
buyUnit: number;
|
||||
sellUnit: number;
|
||||
buy: number;
|
||||
sell: number;
|
||||
};
|
||||
|
||||
export type ComparedItem = {
|
||||
typeId: number;
|
||||
name: string;
|
||||
quantity: number;
|
||||
volume: number;
|
||||
prices: Record<number, LocationPrice>;
|
||||
};
|
||||
|
||||
export type PricesForLocation = (types: MarketType[], locationId: number) => Promise<MarketTypePrice[]>;
|
||||
|
||||
export const compareAppraisal = async (
|
||||
raw: string,
|
||||
locationIds: number[],
|
||||
getPrices: PricesForLocation
|
||||
): Promise<ComparedItem[]> => {
|
||||
if (!raw?.trim() || locationIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stacks = await marketApi.parseStacks(raw).then(r => r.data);
|
||||
|
||||
if (stacks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const quantities = new Map(stacks.map(s => [s.marketTypeId, s.quantity]));
|
||||
const types = await getMarketTypes(stacks.map(s => s.marketTypeId));
|
||||
|
||||
const rows = new Map<number, ComparedItem>(types.map(t => [t.id, {
|
||||
typeId: t.id,
|
||||
name: t.name,
|
||||
quantity: quantities.get(t.id) ?? 0,
|
||||
volume: t.volume,
|
||||
prices: {},
|
||||
}]));
|
||||
|
||||
const pricesByLocation = await Promise.all(
|
||||
locationIds.map(async locationId => [locationId, await getPrices(types, locationId)] as const)
|
||||
);
|
||||
|
||||
pricesByLocation.forEach(([locationId, prices]) => prices.forEach(p => {
|
||||
const row = rows.get(p.type.id);
|
||||
|
||||
if (row) {
|
||||
row.prices[locationId] = {
|
||||
buyUnit: p.buy,
|
||||
sellUnit: p.sell,
|
||||
buy: p.buy * row.quantity,
|
||||
sell: p.sell * row.quantity,
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
return [...rows.values()];
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './MarketTypePrice';
|
||||
export * from './appraisal';
|
||||
export * from './appraise';
|
||||
export * from './compare';
|
||||
|
||||
export { default as AppraisalResultTable } from './AppraisalResultTable.vue';
|
||||
export { default as CompareResultTable } from './CompareResultTable.vue';
|
||||
@@ -0,0 +1,20 @@
|
||||
import {marketApi} from '@/mammon/mammonService';
|
||||
import {MarketTypePrice, PriceGetter} from './MarketTypePrice';
|
||||
|
||||
export const getMammonPrices: PriceGetter = async (types, locationId) => {
|
||||
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), locationId);
|
||||
|
||||
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,25 @@
|
||||
<script setup lang="ts">
|
||||
import {type MarketTrend, MarketTrends} from '@/market';
|
||||
import {ArrowLongRightIcon, ArrowTrendingDownIcon, ArrowTrendingUpIcon} from '@heroicons/vue/24/outline';
|
||||
|
||||
interface Props {
|
||||
trend: MarketTrend;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ArrowTrendingUpIcon v-if="trend === MarketTrends.Up" />
|
||||
<ArrowTrendingDownIcon v-else-if="trend === MarketTrends.Down" />
|
||||
<ArrowLongRightIcon v-else />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/style.css";
|
||||
|
||||
svg {
|
||||
@apply w-6 h-6;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import {type MarketTrend, MarketTrends} from '@/market';
|
||||
import {ArrowLongRightIcon, ArrowTrendingDownIcon, ArrowTrendingUpIcon} from '@heroicons/vue/24/outline';
|
||||
|
||||
const model = defineModel<MarketTrend[]>({ required: true });
|
||||
|
||||
const trends = [
|
||||
{ trend: MarketTrends.Up, icon: ArrowTrendingUpIcon, title: 'Show items trending up' },
|
||||
{ trend: MarketTrends.Flat, icon: ArrowLongRightIcon, title: 'Show items with a flat trend' },
|
||||
{ trend: MarketTrends.Down, icon: ArrowTrendingDownIcon, title: 'Show items trending down' },
|
||||
] as const;
|
||||
|
||||
const toggle = (trend: MarketTrend) => {
|
||||
model.value = model.value.includes(trend)
|
||||
? model.value.filter(t => t !== trend)
|
||||
: [...model.value, trend];
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="trend-filter">
|
||||
<button
|
||||
v-for="{ trend, icon, title } in trends"
|
||||
:key="trend"
|
||||
type="button"
|
||||
:title="title"
|
||||
:class="{ active: model.includes(trend) }"
|
||||
@click="toggle(trend)"
|
||||
>
|
||||
<component :is="icon" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/style.css";
|
||||
|
||||
div.trend-filter {
|
||||
@apply inline-flex rounded overflow-hidden border border-slate-600;
|
||||
}
|
||||
|
||||
div.trend-filter button {
|
||||
@apply p-0.5 border-none rounded-none bg-slate-600 text-slate-100 hover:bg-slate-700 cursor-pointer;
|
||||
}
|
||||
|
||||
div.trend-filter button.active {
|
||||
@apply bg-emerald-500 hover:bg-emerald-600;
|
||||
}
|
||||
|
||||
div.trend-filter button :deep(svg) {
|
||||
@apply w-6 h-6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
HistoryQuartilesResponse,
|
||||
HistoryQuartilesResponseTrendEnum,
|
||||
MarketScanResponseTrendEnum
|
||||
} from "@/generated/mammon";
|
||||
import {marketApi} from "@/mammon";
|
||||
|
||||
export type HistoryQuartiles = HistoryQuartilesResponse;
|
||||
|
||||
export type MarketTrend = MarketScanResponseTrendEnum | HistoryQuartilesResponseTrendEnum;
|
||||
export const MarketTrends = MarketScanResponseTrendEnum as const;
|
||||
|
||||
export const getQuartiles = async (typeId: number, days?: number): Promise<HistoryQuartiles> => (await marketApi.findQuartiles(typeId, days)).data;
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './history.ts';
|
||||
|
||||
export { default as MarketTrendIcon } from './MarketTrendIcon.vue';
|
||||
export { default as TrendFilter } from './TrendFilter.vue';
|
||||
+8
-2
@@ -1,4 +1,10 @@
|
||||
export * from './MarketOrderHistory';
|
||||
export * from './market';
|
||||
export * from './history';
|
||||
export * from './location';
|
||||
export * from './order';
|
||||
export * from './tax';
|
||||
export * from './type';
|
||||
|
||||
export * from './appraisal';
|
||||
|
||||
export { default as IskLabel } from './IskLabel.vue';
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {locationApi} from '@/mammon/mammonService';
|
||||
import type {DockableLocationResponse} from '@/generated/mammon';
|
||||
|
||||
export type MarketLocation = DockableLocationResponse;
|
||||
|
||||
const cache = new Map<number, MarketLocation>();
|
||||
|
||||
const defaultHubIds = [
|
||||
60003760, // Jita IV - Moon 4 - Caldari Navy Assembly Plant
|
||||
60008494, // Amarr VIII (Oris) - Emperor Family Academy
|
||||
60011866, // Dodixie IX - Moon 20 - Federation Navy Assembly Plant
|
||||
60004588, // Rens VI - Moon 8 - Brutor Tribe Treasury
|
||||
60005686, // Hek VIII - Moon 12 - Boundless Creation Factory
|
||||
];
|
||||
|
||||
const hubRank = (id: number) => {
|
||||
const rank = defaultHubIds.indexOf(id);
|
||||
return rank < 0 ? defaultHubIds.length : rank;
|
||||
};
|
||||
|
||||
export const isDefaultHub = (id: number): boolean => defaultHubIds.includes(id);
|
||||
|
||||
export const searchMarketLocations = async (search: string): Promise<MarketLocation[]> => {
|
||||
if (search.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const locations = await locationApi.searchLocations(search).then(r => r.data);
|
||||
locations.forEach(l => cache.set(l.id, l));
|
||||
return locations.toSorted((a, b) => hubRank(a.id) - hubRank(b.id));
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import {watchDebounced} from '@vueuse/core';
|
||||
import {ref, watch} from 'vue';
|
||||
import {SelectInput} from '@/components';
|
||||
import {isDefaultHub, MarketLocation, searchMarketLocations} from './MarketLocation';
|
||||
|
||||
const modelValue = defineModel<MarketLocation>();
|
||||
|
||||
const search = ref(modelValue.value?.name ?? '');
|
||||
const suggestions = ref<MarketLocation[]>([]);
|
||||
|
||||
watch(() => modelValue.value, v => {
|
||||
search.value = v?.name ?? '';
|
||||
});
|
||||
|
||||
watchDebounced(search, async value => {
|
||||
const term = value.trim();
|
||||
|
||||
if (term.length < 3 || term === modelValue.value?.name) {
|
||||
suggestions.value = [];
|
||||
} else {
|
||||
suggestions.value = await searchMarketLocations(term);
|
||||
}
|
||||
}, {debounce: 300});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectInput v-model="modelValue" :items="suggestions" class="w-96">
|
||||
<template #input>
|
||||
<input type="text" v-model="search" placeholder="Search a market location…" />
|
||||
</template>
|
||||
<template #item="{ item }">
|
||||
<div class="px-1 whitespace-nowrap overflow-hidden text-ellipsis" :class="{'text-emerald-400': isDefaultHub(item.id)}">
|
||||
{{ item.name }} <span class="text-slate-300">({{ item.regionName }})</span>
|
||||
</div>
|
||||
</template>
|
||||
</SelectInput>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/style.css";
|
||||
input {
|
||||
@apply w-full border-none bg-transparent block focus-visible:outline-none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './location';
|
||||
export * from './MarketLocation';
|
||||
export { default as MarketLocationInput } from './MarketLocationInput.vue';
|
||||
@@ -0,0 +1,18 @@
|
||||
import {useLocalStorage} from "@vueuse/core";
|
||||
import {defineStore} from "pinia";
|
||||
import type {MarketLocation} from "./MarketLocation";
|
||||
|
||||
const defaultLocation: MarketLocation = {
|
||||
id: 60003760,
|
||||
name: 'Jita IV - Moon 4 - Caldari Navy Assembly Plant',
|
||||
systemId: 30000142,
|
||||
systemName: 'Jita',
|
||||
regionId: 10000002,
|
||||
regionName: 'The Forge',
|
||||
};
|
||||
|
||||
export const useMarketLocationStore = defineStore("marketLocation", () => {
|
||||
const location = useLocalStorage<MarketLocation>("market-location", defaultLocation);
|
||||
|
||||
return {location};
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
|
||||
export const jitaId = 10000002;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './order';
|
||||
@@ -0,0 +1,7 @@
|
||||
import {marketApi} from "@/mammon";
|
||||
import {MarketOrderResponseDirectionEnum} from "@/generated/mammon";
|
||||
|
||||
export const getListedSellTypeIds = (): Promise<Set<number>> => marketApi.findAllMarketOrders()
|
||||
.then(response => new Set(response.data
|
||||
.filter(o => o.direction === MarketOrderResponseDirectionEnum.Sell)
|
||||
.map(o => o.marketTypeId)));
|
||||
@@ -1,59 +0,0 @@
|
||||
import { MarketOrderHistory } from "@/market";
|
||||
|
||||
export type HistoryQuartils = {
|
||||
totalVolume: number,
|
||||
q1: number,
|
||||
median: number,
|
||||
q3: number,
|
||||
}
|
||||
|
||||
export const getHistoryQuartils = (history: MarketOrderHistory[], 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: MarketOrderHistory): number => {
|
||||
if (history.volume === 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(1, Math.round(history.volume * ((history.average - history.lowest) / (history.highest - history.lowest))));
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { formatIsk, percentFormater } from '@/formaters';
|
||||
import { MarketType, MarketTypeLabel } from "@/market";
|
||||
import { SortableHeader, useSort } from '@/table';
|
||||
import { ArrowPathIcon } from '@heroicons/vue/24/outline';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { computed, ref } from 'vue';
|
||||
import { ScanResult, getHistoryQuartils } from '.';
|
||||
import {SliderCheckbox} from '@/components';
|
||||
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
|
||||
import {formatIsk, percentFormater} from "@/formaters";
|
||||
import {MarketTrend, MarketTrendIcon, MarketTrends, MarketType, MarketTypeLabel, TrendFilter} from "@/market";
|
||||
import {useStorage} from '@vueuse/core';
|
||||
import {computed, ref} from 'vue';
|
||||
import {useAcquiredTypesStore} from '../acquisition';
|
||||
import {ScanResult} from './scan';
|
||||
|
||||
type Result = {
|
||||
type: MarketType;
|
||||
@@ -14,32 +15,54 @@ type Result = {
|
||||
buy: number;
|
||||
sell: number;
|
||||
q1: number;
|
||||
mmedian: number;
|
||||
median: number;
|
||||
q3: number;
|
||||
totalVolume: number;
|
||||
acquisitions: number;
|
||||
profit: number;
|
||||
score: number;
|
||||
trend: MarketTrend;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items?: ScanResult[];
|
||||
infoOnly?: boolean;
|
||||
ignoredColumns?: string[] | string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'relaod', type: MarketType): void;
|
||||
(e: 'relaodAll'): 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: () => []
|
||||
items: () => [],
|
||||
infoOnly: false,
|
||||
ignoredColumns: () => []
|
||||
});
|
||||
defineEmits<Emits>();
|
||||
|
||||
const days = useStorage('market-scan-days', 365);
|
||||
const acquiredTypesStore = useAcquiredTypesStore();
|
||||
|
||||
const threshold = useStorage('market-scan-threshold', 10);
|
||||
const filter = ref("");
|
||||
const { sortedArray, headerProps } = useSort<Result>(computed(() => props.items
|
||||
const onlyCheap = ref(false);
|
||||
const trackedTrends = useStorage<MarketTrend[]>('market-scan-trends', [
|
||||
MarketTrends.Up,
|
||||
MarketTrends.Flat,
|
||||
MarketTrends.Down,
|
||||
]);
|
||||
const columnsToIgnore = computed(() =>
|
||||
typeof props.ignoredColumns === 'string' ? [props.ignoredColumns] : props.ignoredColumns
|
||||
);
|
||||
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => props.items
|
||||
.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase()))
|
||||
.filter(r => trackedTrends.value.includes(r.trend))
|
||||
.map(r => {
|
||||
const quartils = getHistoryQuartils(r.history, days.value);
|
||||
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,
|
||||
@@ -47,19 +70,26 @@ const { sortedArray, headerProps } = useSort<Result>(computed(() => props.items
|
||||
name: r.type.name,
|
||||
buy: r.buy,
|
||||
sell: r.sell,
|
||||
q1: quartils.q1,
|
||||
mmedian: quartils.median,
|
||||
q3: quartils.q3,
|
||||
profit: (quartils.q3 / quartils.q1) -1
|
||||
q1: r.q1,
|
||||
median: r.median,
|
||||
q3: r.q3,
|
||||
totalVolume: r.totalVolume,
|
||||
acquisitions,
|
||||
profit: r.profit,
|
||||
score: r.score,
|
||||
trend: r.trend,
|
||||
};
|
||||
})), {
|
||||
defaultSortKey: 'name',
|
||||
defaultSortDirection: 'asc'
|
||||
}).filter(r => !onlyCheap.value || (r.buy <= r.q1 && r.profit >= (threshold.value / 100)))), {
|
||||
defaultSortKey: 'score',
|
||||
defaultSortDirection: 'desc',
|
||||
ignoredColumns: columnsToIgnore
|
||||
})
|
||||
const getLineColor = (result: Result) => {
|
||||
if (result.profit < (threshold.value / 100)) {
|
||||
if (props.infoOnly) {
|
||||
return '';
|
||||
} else if (result.profit < (threshold.value / 100)) {
|
||||
return 'line-red';
|
||||
} else if (result.sell <= result.q1) {
|
||||
} else if (result.sell > 0 && result.sell <= result.q1) {
|
||||
return 'line-blue';
|
||||
} else if (result.buy <= result.q1) {
|
||||
return 'line-green';
|
||||
@@ -69,26 +99,26 @@ const getLineColor = (result: Result) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex">
|
||||
<div class="flex justify-self-end mb-2 mt-4 ms-auto">
|
||||
<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">
|
||||
<span>Days: </span>
|
||||
<input type="number" min="1" max="365" step="1" v-model="days" />
|
||||
<div class="end flex">
|
||||
<SliderCheckbox class="me-1" v-model="onlyCheap" /> Show only cheap items
|
||||
</div>
|
||||
<div class="end flex items-center">
|
||||
<TrendFilter v-model="trackedTrends" />
|
||||
</div>
|
||||
<div class="end">
|
||||
<span>Filter: </span>
|
||||
<input type="search" class="w-96" v-model="filter" >
|
||||
</div>
|
||||
<div class="end">
|
||||
<button class="flex" @click="$emit('relaodAll')"><ArrowPathIcon class="stroke-slate-100 sh-6 w-6 me-2" />Reload all</button>
|
||||
<input type="search" class="w-96" v-model="filter" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem">
|
||||
<template #default="{ list }">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
|
||||
@@ -97,30 +127,42 @@ const getLineColor = (result: Result) => {
|
||||
<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>
|
||||
<th></th>
|
||||
<SortableHeader v-bind="headerProps" sortKey="score">Score</SortableHeader>
|
||||
<SortableHeader v-bind="headerProps" sortKey="acquisitions">Acquisitions</SortableHeader>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in sortedArray" :key="r.typeID" :class="getLineColor(r)">
|
||||
<td>
|
||||
<MarketTypeLabel :id="r.typeID" :name="r.name" />
|
||||
</td>
|
||||
<td class="text-right">{{ formatIsk(r.buy) }}</td>
|
||||
<td class="text-right">{{ formatIsk(r.sell) }}</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.profit) }}</td>
|
||||
<td class="text-right">
|
||||
<button class="btn-icon-stroke" @click="$emit('relaod', r.type)"><ArrowPathIcon /></button>
|
||||
<tr v-for="r in list" :key="r.data.typeID" :class="getLineColor(r.data)">
|
||||
<td v-if="showColumn('name')">
|
||||
<div class="flex">
|
||||
<MarketTypeLabel :id="r.data.typeID" :name="r.data.name" />
|
||||
<MarketTrendIcon class="ms-auto" :trend="r.data.trend" />
|
||||
</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('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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './HistoryQuartils';
|
||||
export * from './scan';
|
||||
|
||||
export { default as ScanResultTable } from './ScanResultTable.vue';
|
||||
|
||||
+23
-4
@@ -1,9 +1,28 @@
|
||||
import { MarketOrderHistory, MarketType } from "@/market";
|
||||
import {MarketTrend, MarketType} from "@/market";
|
||||
import {MarketScanResponse} from "@/generated/mammon";
|
||||
|
||||
export type ScanResult = {
|
||||
type: MarketType;
|
||||
history: MarketOrderHistory[];
|
||||
buy: number,
|
||||
sell: number
|
||||
buy: number;
|
||||
sell: number;
|
||||
q1: number;
|
||||
median: number;
|
||||
q3: number;
|
||||
totalVolume: number;
|
||||
profit: number;
|
||||
score: number;
|
||||
trend: MarketTrend;
|
||||
}
|
||||
|
||||
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,
|
||||
trend: res.trend,
|
||||
});
|
||||
|
||||
@@ -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.375" 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.375);
|
||||
|
||||
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,24 +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 [];
|
||||
} else if (types.length === 1 && typeof types[0] === "number") {
|
||||
return [(await apiAxiosInstance.get<MarketType>(`/sde/types/${types[0]}/`)).data];
|
||||
}
|
||||
return (await apiAxiosInstance.post<MarketType[]>("/sde/types/search", types.map(t => [typeof t === "number" ? 'id' : "name", t]))).data;
|
||||
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 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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user