New eveal #32
@@ -23,3 +23,5 @@ docker-compose.yml
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
generated/mammon/
|
||||||
+2006
File diff suppressed because it is too large
Load Diff
Generated
+1971
-2530
File diff suppressed because it is too large
Load Diff
+17
-17
@@ -11,29 +11,29 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/vue": "^2.0.18",
|
"@heroicons/vue": "^2.0.18",
|
||||||
"@vueuse/components": "^10.5.0",
|
"@vueuse/components": "^14.3.0",
|
||||||
"@vueuse/core": "^10.2.1",
|
"@vueuse/core": "^14.3.0",
|
||||||
"@vueuse/integrations": "^10.2.1",
|
"@vueuse/integrations": "^14.3.0",
|
||||||
|
"@vueuse/router": "^14.3.0",
|
||||||
"axios": "^1.4.0",
|
"axios": "^1.4.0",
|
||||||
"axios-rate-limit": "^1.3.1",
|
|
||||||
"gemory": "file:",
|
"gemory": "file:",
|
||||||
"loglevel": "^1.8.1",
|
"loglevel": "^1.8.1",
|
||||||
"loglevel-plugin-prefix": "^0.8.4",
|
"loglevel-plugin-prefix": "^0.8.4",
|
||||||
"oidc-client-ts": "^3.0.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"pinia": "^2.1.6",
|
"pinia": "^3.0.4",
|
||||||
|
"sortablejs": "^1.15.7",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.3.4",
|
||||||
"vue-router": "^4.2.4"
|
"vue-router": "^5.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.4.5",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@types/node": "^25.8.0",
|
||||||
"autoprefixer": "^10.4.14",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
"postcss": "^8.4.27",
|
"tailwindcss": "^4.3.0",
|
||||||
"tailwindcss": "^3.3.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript": "^5.0.2",
|
"vite": "^8.0.13",
|
||||||
"vite": "^6.3.5",
|
"vite-plugin-runtime-env": "^1.0.0",
|
||||||
"vite-plugin-runtime-env": "^0.1.1",
|
"vitest": "^4.1.6",
|
||||||
"vitest": "^3.1.3",
|
"vue-tsc": "^3.2.9"
|
||||||
"vue-tsc": "^2.0.18"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
'postcss-import': {},
|
|
||||||
'tailwindcss/nesting': {},
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
+10
-7
@@ -1,14 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAuthStore } from '@/auth';
|
import {computed} from 'vue';
|
||||||
import { computed } from 'vue';
|
import {RouterView, useRoute} from 'vue-router';
|
||||||
import { RouterView, useRoute } from 'vue-router';
|
import {Sidebar} from './sidebar';
|
||||||
import { Sidebar } from './sidebar';
|
import {ConfirmModal} from '@/confirm';
|
||||||
|
import {routeNames} from '@/routes';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const authStore = useAuthStore();
|
|
||||||
|
|
||||||
const hideSidebar = computed(() => {
|
const hideSidebar = computed(() => {
|
||||||
return !authStore.isLoggedIn || route.name === 'callback' || route.name === 'about';
|
return route.name === routeNames.callback || route.name === routeNames.about;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -22,9 +22,12 @@ const hideSidebar = computed(() => {
|
|||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<ConfirmModal />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
|
||||||
div.main-container {
|
div.main-container {
|
||||||
@apply px-4 sm:ml-64;
|
@apply px-4 sm:ml-64;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<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} from "@/corporations";
|
||||||
|
import {LedgerLabel, useLedgersStore} from "@/ledger";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
source: ActivitySourceResponse;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
size: 32
|
||||||
|
});
|
||||||
|
|
||||||
|
const charactersStore = useCharactersStore();
|
||||||
|
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 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" :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;
|
||||||
|
};
|
||||||
-49
@@ -1,49 +0,0 @@
|
|||||||
import log from "loglevel";
|
|
||||||
import { Log, User, UserManager, WebStorageStateStore } from "oidc-client-ts";
|
|
||||||
import { defineStore } from "pinia";
|
|
||||||
import { computed, ref } from "vue";
|
|
||||||
|
|
||||||
Log.setLogger(log);
|
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
|
||||||
const userManager = new UserManager({
|
|
||||||
authority: import.meta.env.VITE_AUTH_AUTHORITY,
|
|
||||||
client_id: import.meta.env.VITE_AUTH_CLIENT_ID,
|
|
||||||
client_secret: import.meta.env.VITE_AUTH_CLIENT_SECRET,
|
|
||||||
redirect_uri: import.meta.env.VITE_AUTH_REDIRECT_URI,
|
|
||||||
scope: import.meta.env.VITE_AUTH_SCOPE,
|
|
||||||
stateStore: new WebStorageStateStore({ store: window.localStorage }),
|
|
||||||
userStore: new WebStorageStateStore({ store: window.localStorage })
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = ref<User>();
|
|
||||||
const isLoggedIn = computed(() => user.value?.expired === false);
|
|
||||||
const accessToken = computed(() => user.value?.access_token);
|
|
||||||
const username = computed(() => user.value?.profile.name ?? "");
|
|
||||||
const userId = computed(() => user.value?.profile.sub ?? "");
|
|
||||||
|
|
||||||
const redirect = async () => {
|
|
||||||
await userManager.signinRedirect();
|
|
||||||
log.info("Redirecting to login page");
|
|
||||||
}
|
|
||||||
const login = async () => {
|
|
||||||
await userManager.signinCallback();
|
|
||||||
log.debug("Logged in");
|
|
||||||
}
|
|
||||||
const logout = async () => {
|
|
||||||
await userManager.signoutRedirect();
|
|
||||||
log.debug("Logged out");
|
|
||||||
}
|
|
||||||
|
|
||||||
const setUser = (u?: User | null) => {
|
|
||||||
if (u) {
|
|
||||||
user.value = u;
|
|
||||||
log.debug("User loaded", u.profile.name);
|
|
||||||
} else {
|
|
||||||
user.value = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
userManager.events.addUserLoaded(setUser);
|
|
||||||
userManager.getUser().then(setUser);
|
|
||||||
return { redirect, login, logout, isLoggedIn, accessToken, username, userId };
|
|
||||||
});
|
|
||||||
@@ -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';
|
||||||
+69
-32
@@ -1,59 +1,96 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/vue/24/outline';
|
import {ChevronDownIcon, ChevronUpIcon} from '@heroicons/vue/24/outline';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import {vOnClickOutside} from '@vueuse/components';
|
||||||
import { useEventListener } from '@vueuse/core';
|
import {useElementBounding, useEventListener} from '@vueuse/core';
|
||||||
import { ref } from 'vue';
|
import {computed, ref} from 'vue';
|
||||||
|
|
||||||
const isOpen = ref(false);
|
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 => {
|
useEventListener('keyup', e => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
isOpen.value = false;
|
doAutoClose();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="dropdown" :class="{'dropdown-open': isOpen, 'dropdown-close': !isOpen}" v-on-click-outside="() => isOpen = false">
|
<div ref="root" class="dropdown" :class="{'dropdown-open': isOpen, 'dropdown-close': !isOpen}" v-on-click-outside="[doAutoClose, { ignore: [floating] }]">
|
||||||
<button @click="isOpen = !isOpen">
|
<button @click="isOpen = !isOpen" class="cursor-pointer">
|
||||||
<slot name="button" />
|
<Transition
|
||||||
<Transition name="flip">
|
enter-active-class="transition-transform"
|
||||||
|
enter-from-class="rotate-180"
|
||||||
|
leave-active-class="hidden"
|
||||||
|
leave-to-class="rotate-180">
|
||||||
<ChevronDownIcon v-if="!isOpen" class="chevron" />
|
<ChevronDownIcon v-if="!isOpen" class="chevron" />
|
||||||
<ChevronUpIcon v-else class="chevron" />
|
<ChevronUpIcon v-else class="chevron" />
|
||||||
</Transition>
|
</Transition>
|
||||||
|
<slot name="button" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Transition name="fade">
|
<Transition
|
||||||
<div v-if="isOpen" class="relative">
|
enter-active-class="transition-opacity"
|
||||||
<div class="z-10 divide-y rounded-b-md absolute">
|
enter-from-class="opacity-0"
|
||||||
<slot />
|
leave-from-class="transition-opacity"
|
||||||
</div>
|
leave-to-class="opacity-0">
|
||||||
|
<div v-if="inline && isOpen">
|
||||||
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
|
||||||
.chevron {
|
.chevron {
|
||||||
@apply w-4 h-4 ms-1;
|
@apply w-4 h-4 me-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-enter-from, .fade-leave-to {
|
.dropdown-floating {
|
||||||
opacity: 0;
|
@apply fixed z-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-enter-active, .fade-leave-active {
|
.dropdown-floating > div {
|
||||||
@apply transition-opacity;
|
@apply bg-slate-800 rounded-b-md shadow-lg;
|
||||||
}
|
|
||||||
|
|
||||||
.flip-enter-from, .flip-leave-to {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.flip-enter-active {
|
|
||||||
@apply transition-transform;
|
|
||||||
}
|
|
||||||
.flip-leave-active {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import {vOnClickOutside} from '@vueuse/components';
|
||||||
import { useEventListener } from '@vueuse/core';
|
import {useEventListener} from '@vueuse/core';
|
||||||
import { watch } from 'vue';
|
import {watch} from 'vue';
|
||||||
|
|
||||||
const open = defineModel('open', { default: false });
|
const open = defineModel('open', { default: false });
|
||||||
|
|
||||||
@@ -34,7 +34,8 @@ useEventListener('keyup', e => {
|
|||||||
</Transition>
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
.fade-enter-from, .fade-leave-to {
|
.fade-enter-from, .fade-leave-to {
|
||||||
@apply opacity-0;
|
@apply opacity-0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts" generic="T">
|
||||||
|
import {vOnClickOutside} from '@vueuse/components';
|
||||||
|
import {useVirtualList} from '@vueuse/core';
|
||||||
|
import {computed, ref} from 'vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: T[];
|
||||||
|
itemHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
itemHeight: 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelValue = defineModel<T>();
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const currentIndex = ref(-1);
|
||||||
|
const {list, scrollTo, containerProps, wrapperProps} = useVirtualList(computed(() => props.items), {
|
||||||
|
itemHeight: () => props.itemHeight,
|
||||||
|
overscan: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveDown = () => {
|
||||||
|
currentIndex.value = currentIndex.value >= props.items.length - 1 ? 0 : currentIndex.value + 1;
|
||||||
|
scrollTo(currentIndex.value);
|
||||||
|
};
|
||||||
|
const moveUp = () => {
|
||||||
|
currentIndex.value = currentIndex.value <= 0 ? props.items.length - 1 : currentIndex.value - 1;
|
||||||
|
scrollTo(currentIndex.value);
|
||||||
|
};
|
||||||
|
const select = (item?: T) => {
|
||||||
|
modelValue.value = item;
|
||||||
|
currentIndex.value = -1;
|
||||||
|
isOpen.value = false;
|
||||||
|
};
|
||||||
|
const submit = () => {
|
||||||
|
if (currentIndex.value >= 0 && currentIndex.value < props.items.length) {
|
||||||
|
select(props.items[currentIndex.value]);
|
||||||
|
} else if (modelValue.value === undefined && props.items.length > 0) {
|
||||||
|
select(props.items[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div 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>
|
||||||
@@ -10,8 +10,10 @@ const modelValue = defineModel({ default: false });
|
|||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
input:checked ~ span:last-child {
|
@reference "@/style.css";
|
||||||
--tw-translate-x: 1.25rem;
|
|
||||||
}
|
input:checked ~ span:last-child {
|
||||||
|
transform: translateX(1.25rem);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -3,6 +3,7 @@ export { default as Dropdown } from './Dropdown.vue';
|
|||||||
export { default as LoadingSpinner } from './LoadingSpinner.vue';
|
export { default as LoadingSpinner } from './LoadingSpinner.vue';
|
||||||
export { default as Modal } from './Modal.vue';
|
export { default as Modal } from './Modal.vue';
|
||||||
export { default as ProgressBar } from './ProgressBar.vue';
|
export { default as ProgressBar } from './ProgressBar.vue';
|
||||||
|
export { default as SelectInput } from './SelectInput.vue';
|
||||||
export { default as SliderCheckbox } from './SliderCheckbox.vue';
|
export { default as SliderCheckbox } from './SliderCheckbox.vue';
|
||||||
export { default as Tooltip } from './tooltip/Tooltip.vue';
|
export { default as Tooltip } from './tooltip/Tooltip.vue';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { HeaderComponent, SortDirection } from './sort';
|
import {HeaderComponent, SortDirection} from './sort';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentSortKey: string | null;
|
currentSortKey: string | null;
|
||||||
@@ -32,7 +32,8 @@ const emit = defineEmits<Emit>();
|
|||||||
</component>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
.sort-header {
|
.sort-header {
|
||||||
@apply relative h-8 pe-3;
|
@apply relative h-8 pe-3;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useElementBounding, useVirtualList } from '@vueuse/core';
|
import {useElementBounding, useVirtualList} from '@vueuse/core';
|
||||||
import { computed, ref } from 'vue';
|
import {computed, ref} from 'vue';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
list?: any[];
|
list?: any[];
|
||||||
@@ -67,29 +67,32 @@ const itemHeightStyle = computed(() => {
|
|||||||
<slot v-else name="empty" />
|
<slot v-else name="empty" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
|
||||||
div.table-container {
|
div.table-container {
|
||||||
@apply bg-slate-600;
|
@apply bg-slate-600;
|
||||||
max-height: calc(100vh - v-bind(ypx));
|
max-height: calc(100vh - v-bind(ypx));
|
||||||
:deep(>div) {
|
|
||||||
@apply bg-slate-800;
|
&::-webkit-scrollbar-track {
|
||||||
>table {
|
margin-top: v-bind(computedHeaderHeight);
|
||||||
>thead {
|
margin-bottom: v-bind(computedFooterHeight);
|
||||||
@apply sticky z-10;
|
}
|
||||||
top: -1px;
|
}
|
||||||
}
|
div.table-container:deep(>div) {
|
||||||
>tfoot {
|
@apply bg-slate-800;
|
||||||
@apply bg-slate-600 sticky z-10;
|
>table {
|
||||||
bottom: -1px;
|
>thead {
|
||||||
}
|
@apply sticky z-10;
|
||||||
>*>tr, >*>tr>td {
|
top: -1px;
|
||||||
height: v-bind(itemHeightStyle);
|
}
|
||||||
}
|
>tfoot {
|
||||||
|
@apply bg-slate-600 sticky z-10;
|
||||||
|
bottom: -1px;
|
||||||
|
}
|
||||||
|
>*>tr, >*>tr>td {
|
||||||
|
height: v-bind(itemHeightStyle);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
&::-webkit-scrollbar-track {
|
|
||||||
margin-top: v-bind(computedHeaderHeight);
|
|
||||||
margin-bottom: v-bind(computedFooterHeight);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -37,7 +37,7 @@ describe('useSort', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('Hides ignored columns', () => {
|
test('Hides ignored columns', () => {
|
||||||
const { showColumn } = useSort(array, { ignoredColums: ['key1'] })
|
const { showColumn } = useSort(array, { ignoredColumns: ['key1'] })
|
||||||
|
|
||||||
expect(showColumn('key1')).toBe(false)
|
expect(showColumn('key1')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export type SortDirection = "asc" | "desc";
|
|||||||
export type UseSortOptions = {
|
export type UseSortOptions = {
|
||||||
defaultSortKey?: string;
|
defaultSortKey?: string;
|
||||||
defaultSortDirection?: SortDirection;
|
defaultSortDirection?: SortDirection;
|
||||||
ignoredColums?: MaybeRefOrGetter<string[]>;
|
ignoredColumns?: MaybeRefOrGetter<string[]>;
|
||||||
headerComponent?: HeaderComponent;
|
headerComponent?: HeaderComponent;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ export const useSort = <T>(array: MaybeRefOrGetter<T[]>, options?: UseSortOption
|
|||||||
sortKey.value = key;
|
sortKey.value = key;
|
||||||
sortDirection.value = direction;
|
sortDirection.value = direction;
|
||||||
};
|
};
|
||||||
const showColumn = (sortKey: string) => !toValue(options?.ignoredColums)?.includes(sortKey);
|
const showColumn = (sortKey: string) => !toValue(options?.ignoredColumns)?.includes(sortKey);
|
||||||
const headerProps = computed(() => ({
|
const headerProps = computed(() => ({
|
||||||
onSort: sortBy,
|
onSort: sortBy,
|
||||||
showColumn,
|
showColumn,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { vElementHover } from '@vueuse/components';
|
import {vElementHover} from '@vueuse/components';
|
||||||
import { useElementBounding } from '@vueuse/core';
|
import {useElementBounding} from '@vueuse/core';
|
||||||
import { computed, ref } from 'vue';
|
import {computed, ref} from 'vue';
|
||||||
import { useSharedWindowSize } from './tooltip';
|
import {useSharedWindowSize} from './tooltip';
|
||||||
|
|
||||||
const open = defineModel('open', { default: false });
|
const open = defineModel('open', { default: false });
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ const positions = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="mainDiv" clas="flex flex-col items-center justify-center" :class="{
|
<div ref="mainDiv" class="flex flex-col items-center justify-center" :class="{
|
||||||
'open': open,
|
'open': open,
|
||||||
'tooltip-top': positions.includes('top'),
|
'tooltip-top': positions.includes('top'),
|
||||||
'tooltip-bottom': positions.includes('bottom'),
|
'tooltip-bottom': positions.includes('bottom'),
|
||||||
|
|||||||
@@ -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,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
corporationId: number;
|
||||||
|
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<template v-if="division"> · Division {{ division }}</template></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export {default as CorporationLabel} from './CorporationLabel.vue';
|
||||||
@@ -11,7 +11,6 @@ export const percentFormater = new Intl.NumberFormat("en-US", {
|
|||||||
maximumFractionDigits: 0
|
maximumFractionDigits: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const timeFormat = new Intl.NumberFormat("en-US", {
|
const timeFormat = new Intl.NumberFormat("en-US", {
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* OpenAPI definition
|
||||||
|
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
|
||||||
|
*
|
||||||
|
* The version of the OpenAPI document: v0
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||||
|
* https://openapi-generator.tech
|
||||||
|
* Do not edit the class manually.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
import type { Configuration } from './configuration';
|
||||||
|
// Some imports not used depending on template conditions
|
||||||
|
// @ts-ignore
|
||||||
|
import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';
|
||||||
|
import globalAxios from 'axios';
|
||||||
|
|
||||||
|
export const BASE_PATH = "http://localhost:8080".replace(/\/+$/, "");
|
||||||
|
|
||||||
|
export const COLLECTION_FORMATS = {
|
||||||
|
csv: ",",
|
||||||
|
ssv: " ",
|
||||||
|
tsv: "\t",
|
||||||
|
pipes: "|",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface RequestArgs {
|
||||||
|
url: string;
|
||||||
|
options: RawAxiosRequestConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BaseAPI {
|
||||||
|
protected configuration: Configuration | undefined;
|
||||||
|
|
||||||
|
constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) {
|
||||||
|
if (configuration) {
|
||||||
|
this.configuration = configuration;
|
||||||
|
this.basePath = configuration.basePath ?? basePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export class RequiredError extends Error {
|
||||||
|
constructor(public field: string, msg?: string) {
|
||||||
|
super(msg);
|
||||||
|
this.name = "RequiredError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerMap {
|
||||||
|
[key: string]: {
|
||||||
|
url: string,
|
||||||
|
description: string,
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const operationServerMap: ServerMap = {
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* OpenAPI definition
|
||||||
|
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
|
||||||
|
*
|
||||||
|
* The version of the OpenAPI document: v0
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||||
|
* https://openapi-generator.tech
|
||||||
|
* Do not edit the class manually.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Configuration } from "./configuration";
|
||||||
|
import type { RequestArgs } from "./base";
|
||||||
|
import type { AxiosInstance, AxiosResponse } from 'axios';
|
||||||
|
import { RequiredError } from "./base";
|
||||||
|
|
||||||
|
export const DUMMY_BASE_URL = 'https://example.com'
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) {
|
||||||
|
if (paramValue === null || paramValue === undefined) {
|
||||||
|
throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) {
|
||||||
|
if (configuration && configuration.apiKey) {
|
||||||
|
const localVarApiKeyValue = typeof configuration.apiKey === 'function'
|
||||||
|
? await configuration.apiKey(keyParamName)
|
||||||
|
: await configuration.apiKey;
|
||||||
|
object[keyParamName] = localVarApiKeyValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setBasicAuthToObject = function (object: any, configuration?: Configuration) {
|
||||||
|
if (configuration && (configuration.username || configuration.password)) {
|
||||||
|
object["auth"] = { username: configuration.username, password: configuration.password };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) {
|
||||||
|
if (configuration && configuration.accessToken) {
|
||||||
|
const accessToken = typeof configuration.accessToken === 'function'
|
||||||
|
? await configuration.accessToken()
|
||||||
|
: await configuration.accessToken;
|
||||||
|
object["Authorization"] = "Bearer " + accessToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) {
|
||||||
|
if (configuration && configuration.accessToken) {
|
||||||
|
const localVarAccessTokenValue = typeof configuration.accessToken === 'function'
|
||||||
|
? await configuration.accessToken(name, scopes)
|
||||||
|
: await configuration.accessToken;
|
||||||
|
object["Authorization"] = "Bearer " + localVarAccessTokenValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function setFlattenedQueryParams(urlSearchParams: URLSearchParams, parameter: any, key: string = ""): void {
|
||||||
|
if (parameter == null) return;
|
||||||
|
if (typeof parameter === "object") {
|
||||||
|
if (Array.isArray(parameter) || parameter instanceof Set) {
|
||||||
|
(parameter as any[]).forEach(item => setFlattenedQueryParams(urlSearchParams, item, key));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Object.keys(parameter).forEach(currentKey =>
|
||||||
|
setFlattenedQueryParams(urlSearchParams, parameter[currentKey], `${key}${key !== '' ? '.' : ''}${currentKey}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (urlSearchParams.has(key)) {
|
||||||
|
urlSearchParams.append(key, parameter);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
urlSearchParams.set(key, parameter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setSearchParams = function (url: URL, ...objects: any[]) {
|
||||||
|
const searchParams = new URLSearchParams(url.search);
|
||||||
|
setFlattenedQueryParams(searchParams, objects);
|
||||||
|
url.search = searchParams.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON serialization helper function which replaces instances of unserializable types with serializable ones.
|
||||||
|
* This function will run for every key-value pair encountered by JSON.stringify while traversing an object.
|
||||||
|
* Converting a set to a string will return an empty object, so an intermediate conversion to an array is required.
|
||||||
|
*/
|
||||||
|
// @ts-ignore
|
||||||
|
export const replaceWithSerializableTypeIfNeeded = function(key: string, value: any) {
|
||||||
|
if (value instanceof Set) {
|
||||||
|
return Array.from(value);
|
||||||
|
} else {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) {
|
||||||
|
const nonString = typeof value !== 'string';
|
||||||
|
const needsSerialization = nonString && configuration && configuration.isJsonMime
|
||||||
|
? configuration.isJsonMime(requestOptions.headers['Content-Type'])
|
||||||
|
: nonString;
|
||||||
|
return needsSerialization
|
||||||
|
? JSON.stringify(value !== undefined ? value : {}, replaceWithSerializableTypeIfNeeded)
|
||||||
|
: (value || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toPathString = function (url: URL) {
|
||||||
|
return url.pathname + url.search + url.hash
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) {
|
||||||
|
return <T = unknown, R = AxiosResponse<T>>(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => {
|
||||||
|
const axiosRequestArgs = {...axiosArgs.options, url: (axios.defaults.baseURL ? '' : configuration?.basePath ?? basePath) + axiosArgs.url};
|
||||||
|
return axios.request<T, R>(axiosRequestArgs);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/**
|
||||||
|
* OpenAPI definition
|
||||||
|
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
|
||||||
|
*
|
||||||
|
* The version of the OpenAPI document: v0
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||||
|
* https://openapi-generator.tech
|
||||||
|
* Do not edit the class manually.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface AWSv4Configuration {
|
||||||
|
options?: {
|
||||||
|
region?: string
|
||||||
|
service?: string
|
||||||
|
}
|
||||||
|
credentials?: {
|
||||||
|
accessKeyId?: string
|
||||||
|
secretAccessKey?: string,
|
||||||
|
sessionToken?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigurationParameters {
|
||||||
|
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
|
||||||
|
awsv4?: AWSv4Configuration;
|
||||||
|
basePath?: string;
|
||||||
|
serverIndex?: number;
|
||||||
|
baseOptions?: any;
|
||||||
|
formDataCtor?: new () => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Configuration {
|
||||||
|
/**
|
||||||
|
* parameter for apiKey security
|
||||||
|
* @param name security name
|
||||||
|
*/
|
||||||
|
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
|
||||||
|
/**
|
||||||
|
* parameter for basic security
|
||||||
|
*/
|
||||||
|
username?: string;
|
||||||
|
/**
|
||||||
|
* parameter for basic security
|
||||||
|
*/
|
||||||
|
password?: string;
|
||||||
|
/**
|
||||||
|
* parameter for oauth2 security
|
||||||
|
* @param name security name
|
||||||
|
* @param scopes oauth2 scope
|
||||||
|
*/
|
||||||
|
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
|
||||||
|
/**
|
||||||
|
* parameter for aws4 signature security
|
||||||
|
* @param {Object} AWS4Signature - AWS4 Signature security
|
||||||
|
* @param {string} options.region - aws region
|
||||||
|
* @param {string} options.service - name of the service.
|
||||||
|
* @param {string} credentials.accessKeyId - aws access key id
|
||||||
|
* @param {string} credentials.secretAccessKey - aws access key
|
||||||
|
* @param {string} credentials.sessionToken - aws session token
|
||||||
|
* @memberof Configuration
|
||||||
|
*/
|
||||||
|
awsv4?: AWSv4Configuration;
|
||||||
|
/**
|
||||||
|
* override base path
|
||||||
|
*/
|
||||||
|
basePath?: string;
|
||||||
|
/**
|
||||||
|
* override server index
|
||||||
|
*/
|
||||||
|
serverIndex?: number;
|
||||||
|
/**
|
||||||
|
* base options for axios calls
|
||||||
|
*/
|
||||||
|
baseOptions?: any;
|
||||||
|
/**
|
||||||
|
* The FormData constructor that will be used to create multipart form data
|
||||||
|
* requests. You can inject this here so that execution environments that
|
||||||
|
* do not support the FormData class can still run the generated client.
|
||||||
|
*
|
||||||
|
* @type {new () => FormData}
|
||||||
|
*/
|
||||||
|
formDataCtor?: new () => any;
|
||||||
|
|
||||||
|
constructor(param: ConfigurationParameters = {}) {
|
||||||
|
this.apiKey = param.apiKey;
|
||||||
|
this.username = param.username;
|
||||||
|
this.password = param.password;
|
||||||
|
this.accessToken = param.accessToken;
|
||||||
|
this.awsv4 = param.awsv4;
|
||||||
|
this.basePath = param.basePath;
|
||||||
|
this.serverIndex = param.serverIndex;
|
||||||
|
this.baseOptions = {
|
||||||
|
...param.baseOptions,
|
||||||
|
headers: {
|
||||||
|
...param.baseOptions?.headers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.formDataCtor = param.formDataCtor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given MIME is a JSON MIME.
|
||||||
|
* JSON MIME examples:
|
||||||
|
* application/json
|
||||||
|
* application/json; charset=UTF8
|
||||||
|
* APPLICATION/JSON
|
||||||
|
* application/vnd.company+json
|
||||||
|
* @param mime - MIME (Multipurpose Internet Mail Extensions)
|
||||||
|
* @return True if the given MIME is JSON, false otherwise.
|
||||||
|
*/
|
||||||
|
public isJsonMime(mime: string): boolean {
|
||||||
|
const jsonMime: RegExp = /^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$/i;
|
||||||
|
return mime !== null && jsonMime.test(mime);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* OpenAPI definition
|
||||||
|
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
|
||||||
|
*
|
||||||
|
* The version of the OpenAPI document: v0
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||||
|
* https://openapi-generator.tech
|
||||||
|
* Do not edit the class manually.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
export * from "./api";
|
||||||
|
export * from "./configuration";
|
||||||
|
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import {computed, ref} from "vue";
|
||||||
|
import {isCombined, Ledger, LedgerType, LedgerTypes, useLedgersStore} from "./ledger";
|
||||||
|
import {Modal} from "@/components";
|
||||||
|
import LedgerLabel from "./LedgerLabel.vue";
|
||||||
|
import {PlusIcon, TrashIcon} from '@heroicons/vue/24/outline';
|
||||||
|
import LedgerSelect from "@/ledger/LedgerSelect.vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ledgerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const ledgersStore = useLedgersStore();
|
||||||
|
|
||||||
|
const modalOpen = ref<boolean>(false);
|
||||||
|
|
||||||
|
const type = ref<LedgerType>(LedgerTypes.Main);
|
||||||
|
const name = ref("");
|
||||||
|
const members = ref<Ledger[]>([]);
|
||||||
|
const selectedLedger = ref<Ledger>();
|
||||||
|
const availableLedgers = computed(() => ledgersStore.ledgers
|
||||||
|
.filter(l => l.ledgerId !== props.ledgerId)
|
||||||
|
.filter(l => !members.value.includes(l)));
|
||||||
|
|
||||||
|
|
||||||
|
const addMember = () => {
|
||||||
|
if (selectedLedger.value && !members.value.includes(selectedLedger.value)) {
|
||||||
|
members.value = [...members.value, selectedLedger.value];
|
||||||
|
selectedLedger.value = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
const ledger = isCreating.value ? undefined : ledgersStore.findById(props.ledgerId);
|
||||||
|
|
||||||
|
if (ledger) {
|
||||||
|
type.value = ledger.type;
|
||||||
|
name.value = ledger.name;
|
||||||
|
members.value = isCombined(ledger) ? ledgersStore.findAllById(ledger.memberLedgerIds) : [];
|
||||||
|
} else {
|
||||||
|
type.value = LedgerTypes.Main;
|
||||||
|
name.value = "";
|
||||||
|
members.value = [];
|
||||||
|
}
|
||||||
|
modalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSave = computed(() => name.value.trim().length > 0);
|
||||||
|
const isCreating = computed(() => props.ledgerId === undefined || props.ledgerId.length === 0);
|
||||||
|
const title = computed(() => {
|
||||||
|
if (isCreating.value) {
|
||||||
|
return `Creating ${type.value === LedgerTypes.Main ? 'Main' : 'Combined'} Ledger`
|
||||||
|
}
|
||||||
|
return `Updating ${name.value}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const create = () => {
|
||||||
|
if (type.value === LedgerTypes.Main) {
|
||||||
|
ledgersStore.createMain({name: name.value})
|
||||||
|
} else {
|
||||||
|
ledgersStore.createCombined({name: name.value, memberLedgerIds: members.value.map(l => l.ledgerId)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
if (type.value === LedgerTypes.Main) {
|
||||||
|
ledgersStore.updateMain(props.ledgerId, {name: name.value})
|
||||||
|
} else {
|
||||||
|
ledgersStore.updateCombined(props.ledgerId, {name: name.value, memberLedgerIds: members.value.map(l => l.ledgerId)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (!canSave.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCreating.value) {
|
||||||
|
create();
|
||||||
|
} else {
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
modalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal v-model:open="modalOpen">
|
||||||
|
<div class="bg-slate-800 rounded pb-4 w-96">
|
||||||
|
<span class="m-2">{{ title }}</span>
|
||||||
|
<hr />
|
||||||
|
<div class="mt-4">
|
||||||
|
<div v-if="!ledgerId" class="flex justify-center">
|
||||||
|
<div class="flex bg-slate-600 rounded-s-md p-1">
|
||||||
|
<button class="switch" :class="{active: type === LedgerTypes.Main}" @click="type = LedgerTypes.Main">Main</button>
|
||||||
|
</div>
|
||||||
|
<div class="switch flex bg-slate-600 rounded-e-md p-1">
|
||||||
|
<button class="switch" :class="{active: type === LedgerTypes.Combined}" @click="type = LedgerTypes.Combined">Combined</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-4">
|
||||||
|
Name:
|
||||||
|
<div class="flex">
|
||||||
|
<input type="text" class="flex grow" v-model="name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="type === LedgerTypes.Combined" class="ms-4 mb-4">
|
||||||
|
Member Ledgers:
|
||||||
|
<div v-for="ledger in members" :key="ledger.ledgerId" class="flex">
|
||||||
|
<LedgerLabel class="flex grow mb-2" :ledger="ledger" />
|
||||||
|
<div class="flex justify-end me-4 ms-2">
|
||||||
|
<button class="btn-icon" @click="members = members.filter(m => m !== ledger)"><TrashIcon /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="availableLedgers.length" class="flex">
|
||||||
|
<LedgerSelect v-model="selectedLedger" class="grow" :ledgers="availableLedgers" />
|
||||||
|
<div class="flex justify-end me-4 ms-2">
|
||||||
|
<button class="btn-icon" @click="addMember"><PlusIcon /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button class="me-4" @click="save" :disabled="!canSave">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
|
||||||
|
button.switch {
|
||||||
|
@apply flex items-center px-4 rounded-md bg-slate-600;
|
||||||
|
&.active {
|
||||||
|
@apply bg-emerald-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import {isCombined, Ledger, systemLedger} from "@/ledger/ledger.ts";
|
||||||
|
import {FolderOpenIcon} from '@heroicons/vue/24/outline';
|
||||||
|
import {RouterLink} from "vue-router";
|
||||||
|
import {routeNames} from "@/routes";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ledger: Ledger;
|
||||||
|
link?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex">
|
||||||
|
<FolderOpenIcon v-if="isCombined(ledger)" class="w-4 me-1" />
|
||||||
|
<div v-else class="w-4 me-1"/>
|
||||||
|
<RouterLink v-if="link" :to="{name: routeNames.viewLedger, params: {ledgerId: ledger.ledgerId}}">{{ ledger.name }}</RouterLink>
|
||||||
|
<span v-else :class="{'system-ledger': ledger === systemLedger}">{{ ledger.name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
|
||||||
|
.system-ledger {
|
||||||
|
@apply text-emerald-400;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {Ledger, systemLedger, useLedgersStore} from "@/ledger/ledger.ts";
|
||||||
|
import {computed} from "vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ledgers?: Ledger[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const ledger = defineModel<Ledger>();
|
||||||
|
const ledgersStore = useLedgersStore();
|
||||||
|
|
||||||
|
const ledgersToUse = computed(() => props.ledgers || ledgersStore.ledgers);
|
||||||
|
const ledgerId = computed({
|
||||||
|
get: () => ledger.value?.ledgerId,
|
||||||
|
set: value => ledger.value = ledgersToUse.value.find(l => l.ledgerId === value)
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<select v-model="ledgerId" :class="{'system-ledger': ledger === systemLedger}">
|
||||||
|
<option v-for="l in ledgersToUse" :key="l.ledgerId" :value="l.ledgerId" :class="{'system-ledger': l === systemLedger}">{{ l.name }}</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
|
||||||
|
.system-ledger {
|
||||||
|
@apply text-emerald-400;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './ledger';
|
||||||
|
|
||||||
|
export {default as LedgerLabel} from './LedgerLabel.vue';
|
||||||
|
export {default as LedgerSelect} from './LedgerSelect.vue';
|
||||||
|
export {default as EditLedgerModal} from './EditLedgerModal.vue';
|
||||||
@@ -0,0 +1,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};
|
||||||
|
}
|
||||||
+13
-14
@@ -1,10 +1,10 @@
|
|||||||
import { useAuthStore } from "@/auth";
|
import {createPinia} from 'pinia';
|
||||||
import { createPinia } from 'pinia';
|
import {createApp} from 'vue';
|
||||||
import { createApp } from 'vue';
|
import {createRouter, createWebHistory} from 'vue-router';
|
||||||
import { createRouter, createWebHistory } from 'vue-router';
|
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import { initLogger } from './logger';
|
import {useAuthStore} from './auth';
|
||||||
import { routes } from './routes';
|
import {initLogger} from './logger';
|
||||||
|
import {routeNames, routes} from './routes';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
|
||||||
initLogger();
|
initLogger();
|
||||||
@@ -20,15 +20,14 @@ app.use(pinia);
|
|||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
router.beforeEach(async to => {
|
await authStore.bootstrap();
|
||||||
if (to.name === 'callback') {
|
|
||||||
await authStore.login();
|
router.beforeEach(to => {
|
||||||
return { name: 'home' };
|
if (!to.meta.public && !authStore.isAuthenticated) {
|
||||||
} else if (!authStore.isLoggedIn) {
|
return {name: routeNames.home};
|
||||||
await authStore.redirect();
|
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
app.mount('#app');
|
||||||
app.mount('#app');
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './mammonService'
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import {logResource} from "@/service";
|
||||||
|
import {getAccessToken, setAccessToken} from "@/auth/token";
|
||||||
|
import axios, {InternalAxiosRequestConfig} from "axios";
|
||||||
|
import {
|
||||||
|
AcquisitionApi,
|
||||||
|
ActivityApi,
|
||||||
|
AuthApi,
|
||||||
|
CharacterApi,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
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 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);
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export type MarbasObject = {
|
|
||||||
id: number;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from './MarbasObject';
|
|
||||||
export * from './marbasService';
|
|
||||||
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { useAuthStore } from "@/auth";
|
|
||||||
import { logResource } from "@/service";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
export const marbasAxiosInstance = axios.create({
|
|
||||||
baseURL: import.meta.env.VITE_MARBAS_URL,
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
|
||||||
|
|
||||||
marbasAxiosInstance.interceptors.request.use(async r => {
|
|
||||||
if (!authStore.isLoggedIn) {
|
|
||||||
await authStore.redirect();
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessToken = authStore.accessToken;
|
|
||||||
|
|
||||||
if (accessToken) {
|
|
||||||
r.headers.Authorization = `Bearer ${accessToken}`;
|
|
||||||
}
|
|
||||||
if (!r.params?.page_size) {
|
|
||||||
r.params = { ...r.params, page_size: 250 };
|
|
||||||
}
|
|
||||||
return r;
|
|
||||||
})
|
|
||||||
logResource(marbasAxiosInstance)
|
|
||||||
marbasAxiosInstance.interceptors.response.use(async r => {
|
|
||||||
if (r.status === 401) {
|
|
||||||
await authStore.redirect();
|
|
||||||
|
|
||||||
return marbasAxiosInstance.request(r.config);
|
|
||||||
}
|
|
||||||
|
|
||||||
let next: string = r.data?.next;
|
|
||||||
let results = r.data?.results;
|
|
||||||
|
|
||||||
if (next) {
|
|
||||||
if (!next.startsWith(import.meta.env.VITE_MARBAS_URL)) { // FIME remove once the API is fixed
|
|
||||||
next = import.meta.env.VITE_MARBAS_URL + next.replace(/http(s)?:\/\/[^/]+\//g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
results = results.concat((await marbasAxiosInstance.request({
|
|
||||||
...r.config,
|
|
||||||
url: next,
|
|
||||||
baseURL: '',
|
|
||||||
})).data);
|
|
||||||
}
|
|
||||||
if (results) {
|
|
||||||
r.data = results;
|
|
||||||
}
|
|
||||||
return r;
|
|
||||||
})
|
|
||||||
@@ -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,10 +1,13 @@
|
|||||||
import { MarketType } from "..";
|
import { MarketType } from "..";
|
||||||
import { MarbasAcquiredType } from "./acquisition";
|
import { RawAcquiredType } from "./acquisition";
|
||||||
|
|
||||||
export type AcquiredType = Omit<MarbasAcquiredType, 'type'> & {
|
export type AcquiredType = Omit<RawAcquiredType, 'type'> & {
|
||||||
type: MarketType,
|
type: MarketType,
|
||||||
buy: number,
|
buy: number,
|
||||||
sell: 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 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`)
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { LoadingSpinner, Tooltip } from '@/components';
|
|
||||||
import { formatIsk } from '@/formaters';
|
|
||||||
import { getHistory, getHistoryQuartils } from '@/market';
|
|
||||||
import { ArrowTrendingDownIcon, ArrowTrendingUpIcon } from '@heroicons/vue/24/outline';
|
|
||||||
import { computedAsync } from '@vueuse/core';
|
|
||||||
import { ref, watchEffect } from 'vue';
|
|
||||||
|
|
||||||
const trendingScale = 3;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: number;
|
|
||||||
buy: number;
|
|
||||||
sell: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
|
||||||
|
|
||||||
const open = ref(false);
|
|
||||||
|
|
||||||
const q1 = ref(0);
|
|
||||||
const median = ref(0);
|
|
||||||
const q3 = ref(0);
|
|
||||||
const lineColor = ref('');
|
|
||||||
const history = computedAsync(() => getHistory(props.id), []);
|
|
||||||
|
|
||||||
watchEffect(async () => {
|
|
||||||
if (!open.value || !props.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const quartils = getHistoryQuartils(history.value);
|
|
||||||
|
|
||||||
q1.value = quartils.q1;
|
|
||||||
median.value = quartils.median;
|
|
||||||
q3.value = quartils.q3;
|
|
||||||
|
|
||||||
if (props.buy >= quartils.q3) {
|
|
||||||
lineColor.value = 'line-blue';
|
|
||||||
} else if (props.sell >= quartils.q3) {
|
|
||||||
lineColor.value = 'line-green';
|
|
||||||
} else {
|
|
||||||
lineColor.value = '';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Tooltip v-model:open="open" class="tooltip">
|
|
||||||
<template #header>
|
|
||||||
<LoadingSpinner v-if="history.length < trendingScale" />
|
|
||||||
<ArrowTrendingUpIcon v-else-if="history[0].average > history[trendingScale - 1].average" />
|
|
||||||
<ArrowTrendingDownIcon v-else />
|
|
||||||
</template>
|
|
||||||
<template #default>
|
|
||||||
<div class="bg-slate-500 -left-1/2 relative tooltip-content" v-if="history.length > 0">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Q1</th>
|
|
||||||
<th>Median</th>
|
|
||||||
<th>Q3</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr :class="lineColor">
|
|
||||||
<td class="text-right text-nowrap">{{ formatIsk(q1) }}</td>
|
|
||||||
<td class="text-right text-nowrap">{{ formatIsk(median) }}</td>
|
|
||||||
<td class="text-right text-nowrap">{{ formatIsk(q3) }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Tooltip>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
|
||||||
.tooltip {
|
|
||||||
@apply ms-auto;
|
|
||||||
>:deep(div.header) {
|
|
||||||
@apply btn-icon px-2;
|
|
||||||
}
|
|
||||||
&.open {
|
|
||||||
&.tooltip-top>:deep(div.header) {
|
|
||||||
@apply rounded-t-md bg-slate-600;
|
|
||||||
}
|
|
||||||
&.tooltip-bottom {
|
|
||||||
.tooltip-content {
|
|
||||||
bottom: 79px;
|
|
||||||
}
|
|
||||||
>:deep(div.header) {
|
|
||||||
@apply rounded-b-md bg-slate-600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<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 -left-1/2 relative 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 .tooltip-content {
|
||||||
|
bottom: 75px;
|
||||||
|
}
|
||||||
|
&.tooltip-bottom>:deep(div.header) {
|
||||||
|
@apply rounded-b-md bg-slate-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tooltip>:deep(div.header) {
|
||||||
|
@apply btn-icon px-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { SortableHeader, useSort, VirtualScrollTable } from '@/components/table';
|
import {SliderCheckbox} from '@/components';
|
||||||
import { formatEveDate, formatIsk, percentFormater } from '@/formaters';
|
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
|
||||||
import { MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore } from "@/market";
|
import {getListedSellTypeIds, MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore} from "@/market";
|
||||||
import { MinusIcon, PlusIcon } from '@heroicons/vue/24/outline';
|
import {MinusIcon, PlusIcon} from '@heroicons/vue/24/outline';
|
||||||
import { useStorage } from '@vueuse/core';
|
import {computedAsync, useStorage} from '@vueuse/core';
|
||||||
import { computed, ref } from 'vue';
|
import {computed, ref} from 'vue';
|
||||||
import { AcquiredType } from './AcquiredType';
|
import {AcquiredType} from './AcquiredType';
|
||||||
import AcquisitionQuantilsTooltip from './AcquisitionQuantilsTooltip.vue';
|
import AcquisitionQuartilesTooltip from './AcquisitionQuartilesTooltip.vue';
|
||||||
|
import {formatEveDate, formatIsk, percentFormater} from "@/formaters.ts";
|
||||||
|
import {Ledger, LedgerLabel, useLedgersStore} from "@/ledger";
|
||||||
|
|
||||||
type Result = {
|
type Result = {
|
||||||
id: number;
|
id: string;
|
||||||
type: MarketType;
|
type: MarketType;
|
||||||
name: string;
|
name: string;
|
||||||
buy: number;
|
buy: number;
|
||||||
@@ -20,6 +22,8 @@ type Result = {
|
|||||||
precentProfit: number;
|
precentProfit: number;
|
||||||
iskProfit: number;
|
iskProfit: number;
|
||||||
date: Date;
|
date: Date;
|
||||||
|
ledger?: Ledger;
|
||||||
|
ledgerName: string;
|
||||||
acquisitions: AcquiredType[];
|
acquisitions: AcquiredType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +31,7 @@ interface Props {
|
|||||||
items?: AcquiredType[];
|
items?: AcquiredType[];
|
||||||
infoOnly?: boolean;
|
infoOnly?: boolean;
|
||||||
showAll?: boolean;
|
showAll?: boolean;
|
||||||
ignoredColums?: string[] | string;
|
ignoredColumns?: string[] | string;
|
||||||
defaultSortKey?: string;
|
defaultSortKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,26 +44,39 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
items: () => [],
|
items: () => [],
|
||||||
infoOnly: false,
|
infoOnly: false,
|
||||||
showAll: false,
|
showAll: false,
|
||||||
ignoredColums: () => [],
|
ignoredColumns: () => [],
|
||||||
defaultSortKey: 'precentProfit',
|
defaultSortKey: 'precentProfit',
|
||||||
});
|
});
|
||||||
defineEmits<Emits>();
|
defineEmits<Emits>();
|
||||||
|
|
||||||
const columnsToIgnore = computed(() => {
|
const columnsToIgnore = computed(() => {
|
||||||
const ic = typeof props.ignoredColums === 'string' ? [props.ignoredColums] : props.ignoredColums;
|
const ignoredColumns = typeof props.ignoredColumns === 'string' ? [props.ignoredColumns] : [...props.ignoredColumns];
|
||||||
|
|
||||||
if (props.infoOnly && !ic.includes('buttons')) {
|
if (props.infoOnly && !ignoredColumns.includes('buttons')) {
|
||||||
return [...ic, 'buttons'];
|
ignoredColumns.push('buttons');
|
||||||
}
|
}
|
||||||
return ic;
|
if (!props.showAll && !ignoredColumns.includes('ledger')) {
|
||||||
|
ignoredColumns.push('ledger');
|
||||||
|
}
|
||||||
|
if (ignoredColumns.includes('ledger')) {
|
||||||
|
ignoredColumns.push('ledgerName');
|
||||||
|
}
|
||||||
|
return ignoredColumns;
|
||||||
});
|
});
|
||||||
|
|
||||||
const marketTaxStore = useMarketTaxStore();
|
const marketTaxStore = useMarketTaxStore();
|
||||||
|
const ledgersStore = useLedgersStore();
|
||||||
|
|
||||||
|
const listedTypeIds = computedAsync(getListedSellTypeIds, new Set<number>());
|
||||||
|
|
||||||
const threshold = useStorage('market-acquisition-threshold', 10);
|
const threshold = useStorage('market-acquisition-threshold', 10);
|
||||||
const filter = ref("");
|
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 { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => {
|
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => {
|
||||||
const filteredItems = props.items.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase()));
|
const filteredItems = props.items.filter(matchesFilter);
|
||||||
|
|
||||||
if (props.showAll) {
|
if (props.showAll) {
|
||||||
return filteredItems.map(r => {
|
return filteredItems.map(r => {
|
||||||
@@ -77,6 +94,8 @@ const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() =>
|
|||||||
precentProfit,
|
precentProfit,
|
||||||
iskProfit: r.price * precentProfit * r.remaining,
|
iskProfit: r.price * precentProfit * r.remaining,
|
||||||
date: r.date,
|
date: r.date,
|
||||||
|
ledger: ledgersStore.findById(r.ledgerId),
|
||||||
|
ledgerName: ledgersStore.findById(r.ledgerId)?.name ?? '',
|
||||||
acquisitions: [r]
|
acquisitions: [r]
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -98,7 +117,7 @@ const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() =>
|
|||||||
const precentProfit = marketTaxStore.calculateProfit(price, first.sell);
|
const precentProfit = marketTaxStore.calculateProfit(price, first.sell);
|
||||||
|
|
||||||
list.push({
|
list.push({
|
||||||
id: typeID,
|
id: typeID.toString(),
|
||||||
type: first.type,
|
type: first.type,
|
||||||
name: first.type.name,
|
name: first.type.name,
|
||||||
buy: first.buy,
|
buy: first.buy,
|
||||||
@@ -109,6 +128,7 @@ const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() =>
|
|||||||
precentProfit,
|
precentProfit,
|
||||||
iskProfit: price * precentProfit * totalRemaining,
|
iskProfit: price * precentProfit * totalRemaining,
|
||||||
date: first.date,
|
date: first.date,
|
||||||
|
ledgerName: '',
|
||||||
acquisitions: group
|
acquisitions: group
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -116,7 +136,7 @@ const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() =>
|
|||||||
}), {
|
}), {
|
||||||
defaultSortKey: props.defaultSortKey,
|
defaultSortKey: props.defaultSortKey,
|
||||||
defaultSortDirection: 'desc',
|
defaultSortDirection: 'desc',
|
||||||
ignoredColums: columnsToIgnore
|
ignoredColumns: columnsToIgnore
|
||||||
})
|
})
|
||||||
const getLineColor = (result: Result) => {
|
const getLineColor = (result: Result) => {
|
||||||
if (result.precentProfit >= (threshold.value / 100)) {
|
if (result.precentProfit >= (threshold.value / 100)) {
|
||||||
@@ -159,6 +179,9 @@ const total = computed(() => {
|
|||||||
<div class="flex" v-if="!infoOnly">
|
<div class="flex" v-if="!infoOnly">
|
||||||
<div class="flex justify-self-end mb-2 mt-4 ms-auto">
|
<div class="flex justify-self-end mb-2 mt-4 ms-auto">
|
||||||
<TaxInput />
|
<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">
|
<div class="end">
|
||||||
<span>Profit Threshold: </span>
|
<span>Profit Threshold: </span>
|
||||||
<input type="number" min="0" max="1000" step="1" v-model="threshold" />
|
<input type="number" min="0" max="1000" step="1" v-model="threshold" />
|
||||||
@@ -176,6 +199,7 @@ const total = computed(() => {
|
|||||||
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
|
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
|
||||||
<SortableHeader v-bind="headerProps" sortKey="buy">Buy</SortableHeader>
|
<SortableHeader v-bind="headerProps" sortKey="buy">Buy</SortableHeader>
|
||||||
<SortableHeader v-bind="headerProps" sortKey="sell">Sell</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="date">Bought at</SortableHeader>
|
||||||
<SortableHeader v-bind="headerProps" sortKey="price">Bought Price</SortableHeader>
|
<SortableHeader v-bind="headerProps" sortKey="price">Bought Price</SortableHeader>
|
||||||
<SortableHeader v-bind="headerProps" sortKey="remaining">Remaining Amount</SortableHeader>
|
<SortableHeader v-bind="headerProps" sortKey="remaining">Remaining Amount</SortableHeader>
|
||||||
@@ -189,11 +213,14 @@ const total = computed(() => {
|
|||||||
<td v-if="showColumn('name')">
|
<td v-if="showColumn('name')">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<MarketTypeLabel :id="r.data.type.id" :name="r.data.name" />
|
<MarketTypeLabel :id="r.data.type.id" :name="r.data.name" />
|
||||||
<AcquisitionQuantilsTooltip :id="r.data.type.id" :buy="r.data.buy" :sell="r.data.sell" />
|
<AcquisitionQuartilesTooltip :id="r.data.type.id" :buy="r.data.buy" :sell="r.data.sell" />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td v-if="showColumn('buy')" class="text-right">{{ formatIsk(r.data.buy) }}</td>
|
<td v-if="showColumn('buy')" class="text-right">{{ formatIsk(r.data.buy) }}</td>
|
||||||
<td v-if="showColumn('sell')" class="text-right">{{ formatIsk(r.data.sell) }}</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('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('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('remaining')" class="text-right">{{ r.data.remaining }}/{{ r.data.quantity }}</td>
|
||||||
@@ -214,9 +241,12 @@ const total = computed(() => {
|
|||||||
<td v-if="showColumn('sell')">
|
<td v-if="showColumn('sell')">
|
||||||
<template v-if="!showColumn('name') && !showColumn('buy')">Total</template>
|
<template v-if="!showColumn('name') && !showColumn('buy')">Total</template>
|
||||||
</td>
|
</td>
|
||||||
<td v-if="showColumn('date')">
|
<td v-if="showColumn('ledger')">
|
||||||
<template v-if="!showColumn('name') && !showColumn('buy') && !showColumn('sell')">Total</template>
|
<template v-if="!showColumn('name') && !showColumn('buy') && !showColumn('sell')">Total</template>
|
||||||
</td>
|
</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">
|
<td v-if="showColumn('price')" class="text-right">
|
||||||
<template v-if="total.sameItem">
|
<template v-if="total.sameItem">
|
||||||
{{ formatIsk(total.price) }}
|
{{ formatIsk(total.price) }}
|
||||||
@@ -245,7 +275,8 @@ const total = computed(() => {
|
|||||||
</VirtualScrollTable>
|
</VirtualScrollTable>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
div.end {
|
div.end {
|
||||||
@apply justify-self-end ms-2;
|
@apply justify-self-end ms-2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {getMarketTypes, MarketTypePrice, useAppraisalStore} from "@/market";
|
||||||
|
import {ref, watch} from 'vue';
|
||||||
|
import {ArrowPathIcon} from '@heroicons/vue/24/outline';
|
||||||
|
import {useAutoRefresh} from '@/composables';
|
||||||
|
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 appraisalStore = useAppraisalStore();
|
||||||
|
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 appraisalStore.getPrices(types);
|
||||||
|
|
||||||
|
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, refresh, {immediate: true});
|
||||||
|
|
||||||
|
const {active: autoRefresh, toggle: toggleAutoRefresh} = useAutoRefresh(() => refresh(), 5 * 60 * 1000);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="ms-auto 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>
|
||||||
|
<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>
|
||||||
@@ -1,23 +1,35 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Modal } from '@/components';
|
import {Modal} from '@/components';
|
||||||
import { formatIsk } from '@/formaters';
|
import {formatIsk} from '@/formaters';
|
||||||
import { MarketType, MarketTypeLabel } from '@/market';
|
import {MarketType, MarketTypeLabel} from '@/market';
|
||||||
import { ref } from 'vue';
|
import {Ledger, LedgerSelect, isMain, useLedgersStore} from '@/ledger';
|
||||||
import { useAcquiredTypesStore } from './acquisition';
|
import {computed, ref} from 'vue';
|
||||||
|
import {fromEveDatetimeInput, toEveDatetimeInput} from './AcquiredType';
|
||||||
|
import {useAcquiredTypesStore} from './acquisition';
|
||||||
|
|
||||||
const acquiredTypesStore = useAcquiredTypesStore();
|
const acquiredTypesStore = useAcquiredTypesStore();
|
||||||
|
const ledgersStore = useLedgersStore();
|
||||||
|
|
||||||
|
const mainLedgers = computed(() => ledgersStore.ledgers.filter(isMain));
|
||||||
|
|
||||||
const modalOpen = ref<boolean>(false);
|
const modalOpen = ref<boolean>(false);
|
||||||
const type = ref<MarketType>();
|
const type = ref<MarketType>();
|
||||||
const suggestions = ref<Record<string, number>>({});
|
const suggestions = ref<Record<string, number>>({});
|
||||||
const price = ref(1000000);
|
const price = ref(1000000);
|
||||||
const count = ref(1);
|
const count = ref(1);
|
||||||
|
const date = ref('');
|
||||||
|
const contextLedgerId = ref<string>();
|
||||||
|
const ledger = ref<Ledger>();
|
||||||
|
|
||||||
const open = (t: MarketType, s?: Record<string, number> | number) => {
|
const ledgerId = computed(() => contextLedgerId.value ?? ledger.value?.ledgerId);
|
||||||
|
|
||||||
|
const open = (t: MarketType, s?: Record<string, number> | number, ledgerId?: string) => {
|
||||||
type.value = t;
|
type.value = t;
|
||||||
count.value = 1;
|
count.value = 1;
|
||||||
|
date.value = toEveDatetimeInput(new Date());
|
||||||
|
contextLedgerId.value = ledgerId;
|
||||||
|
ledger.value = undefined;
|
||||||
|
|
||||||
if (typeof s === 'number') {
|
if (typeof s === 'number') {
|
||||||
suggestions.value = {};
|
suggestions.value = {};
|
||||||
price.value = s;
|
price.value = s;
|
||||||
@@ -30,7 +42,7 @@ const open = (t: MarketType, s?: Record<string, number> | number) => {
|
|||||||
}
|
}
|
||||||
modalOpen.value = true;
|
modalOpen.value = true;
|
||||||
}
|
}
|
||||||
const add = () => {
|
const add = async () => {
|
||||||
const id = type.value?.id;
|
const id = type.value?.id;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -38,7 +50,13 @@ const add = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
acquiredTypesStore.addAcquiredType(id, count.value, price.value);
|
const lid = ledgerId.value;
|
||||||
|
|
||||||
|
if (!lid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await acquiredTypesStore.addAcquiredType(id, count.value, price.value, lid, fromEveDatetimeInput(date.value));
|
||||||
modalOpen.value = false;
|
modalOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +80,15 @@ defineExpose({ open });
|
|||||||
<span>Count: </span>
|
<span>Count: </span>
|
||||||
<input class="ms-2" type="number" min="0" step="1" v-model="count" @keyup.enter="add" />
|
<input class="ms-2" type="number" min="0" step="1" v-model="count" @keyup.enter="add" />
|
||||||
</div>
|
</div>
|
||||||
<button class="mb-auto" @click="add">Add</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Modal } from '@/components';
|
import { Modal } from '@/components';
|
||||||
import { MarketType, MarketTypeLabel } from '@/market';
|
import { MarketType, MarketTypeLabel } from '@/market';
|
||||||
import { ref } from 'vue';
|
import { Ledger, LedgerSelect, isMain, useLedgersStore } from '@/ledger';
|
||||||
import { AcquiredType, acquiredTypesToSorted } from './AcquiredType';
|
import { computed, ref } from 'vue';
|
||||||
|
import { AcquiredType, acquiredTypesToSorted, fromEveDatetimeInput, toEveDatetimeInput } from './AcquiredType';
|
||||||
import { useAcquiredTypesStore } from './acquisition';
|
import { useAcquiredTypesStore } from './acquisition';
|
||||||
|
|
||||||
|
|
||||||
const acquiredTypesStore = useAcquiredTypesStore();
|
const acquiredTypesStore = useAcquiredTypesStore();
|
||||||
|
const ledgersStore = useLedgersStore();
|
||||||
|
|
||||||
|
const mainLedgers = computed(() => ledgersStore.ledgers.filter(isMain));
|
||||||
|
|
||||||
const modalOpen = ref<boolean>(false);
|
const modalOpen = ref<boolean>(false);
|
||||||
const type = ref<MarketType>();
|
const type = ref<MarketType>();
|
||||||
const count = ref(1);
|
const count = ref(1);
|
||||||
|
const date = ref('');
|
||||||
const types = ref<AcquiredType[]>([]);
|
const types = ref<AcquiredType[]>([]);
|
||||||
|
const contextLedgerId = ref<string>();
|
||||||
|
const ledger = ref<Ledger>();
|
||||||
|
|
||||||
const open = (t: AcquiredType[]) => {
|
const ledgerId = computed(() => contextLedgerId.value ?? ledger.value?.ledgerId);
|
||||||
|
|
||||||
|
const open = (t: AcquiredType[], ledgerId?: string) => {
|
||||||
if (t.length === 0) {
|
if (t.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -21,24 +30,35 @@ const open = (t: AcquiredType[]) => {
|
|||||||
types.value = acquiredTypesToSorted(t);
|
types.value = acquiredTypesToSorted(t);
|
||||||
type.value = t[0].type;
|
type.value = t[0].type;
|
||||||
count.value = 1;
|
count.value = 1;
|
||||||
|
date.value = toEveDatetimeInput(new Date());
|
||||||
|
contextLedgerId.value = ledgerId;
|
||||||
|
ledger.value = undefined;
|
||||||
modalOpen.value = true;
|
modalOpen.value = true;
|
||||||
}
|
}
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
if (!types.value) {
|
if (types.value.length === 0) {
|
||||||
modalOpen.value = false;
|
modalOpen.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let c = count.value;
|
const lid = ledgerId.value;
|
||||||
|
|
||||||
for (const type of types.value) {
|
if (!lid) {
|
||||||
const remaining = type.remaining;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await acquiredTypesStore.removeAcquiredType(type.id, c);
|
const datetime = fromEveDatetimeInput(date.value);
|
||||||
c -= remaining;
|
let remaining = count.value;
|
||||||
if (c <= 0) {
|
|
||||||
|
for (const t of types.value) {
|
||||||
|
if (remaining <= 0) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const quantity = Math.min(remaining, t.remaining);
|
||||||
|
|
||||||
|
await acquiredTypesStore.removeAcquiredType(t.id, quantity, lid, datetime);
|
||||||
|
remaining -= quantity;
|
||||||
}
|
}
|
||||||
modalOpen.value = false;
|
modalOpen.value = false;
|
||||||
}
|
}
|
||||||
@@ -61,7 +81,15 @@ defineExpose({ open });
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="mb-auto" @click="remove">Remove</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,75 +1,71 @@
|
|||||||
import { marbasAxiosInstance, MarbasObject } from "@/marbas";
|
import {defineStore} from "pinia";
|
||||||
import { AxiosResponse } from "axios";
|
import {computed, ref} from "vue";
|
||||||
import log from "loglevel";
|
import {acquisitionApi, activityApi} from "@/mammon";
|
||||||
import { defineStore } from "pinia";
|
import {AcquisitionResponse, ActivitySourceResponse} from "@/generated/mammon";
|
||||||
import { computed, ref } from "vue";
|
import {onActivitiesProcessed} from "@/activity";
|
||||||
|
|
||||||
export type AcquiredTypeSource = 'bo' | 'so' | 'prod' | 'misc';
|
export type RawAcquiredType = {
|
||||||
|
id: string;
|
||||||
export type MarbasAcquiredType = MarbasObject & {
|
ledgerId: string;
|
||||||
type: number;
|
type: number;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
remaining: number;
|
remaining: number;
|
||||||
price: number;
|
price: number;
|
||||||
date: Date;
|
date: Date;
|
||||||
source: AcquiredTypeSource;
|
source: ActivitySourceResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RawMarbasAcquiredType = Omit<MarbasAcquiredType, 'date'> & {
|
export const toAcquiredType = (a: AcquisitionResponse): RawAcquiredType => ({
|
||||||
date: string;
|
id: a.acquisitionId,
|
||||||
}
|
ledgerId: a.ledgerId,
|
||||||
|
type: a.marketTypeId,
|
||||||
type InsertableRawMarbasAcquiredType = Omit<MarbasAcquiredType, 'id' | 'date'>;
|
quantity: a.quantity,
|
||||||
|
remaining: a.remaining,
|
||||||
const mapRawMarbasAcquiredType = (raw: RawMarbasAcquiredType): MarbasAcquiredType => ({
|
price: a.unitCost,
|
||||||
...raw,
|
date: new Date(a.datetime),
|
||||||
date: raw.date ? new Date(raw.date) : new Date()
|
source: a.source,
|
||||||
});
|
});
|
||||||
|
|
||||||
const endpoint = '/api/acquisitions/';
|
|
||||||
|
|
||||||
export const useAcquiredTypesStore = defineStore('market-acquisition', () => {
|
export const useAcquiredTypesStore = defineStore('market-acquisition', () => {
|
||||||
const acquiredTypes = ref<MarbasAcquiredType[]>([]);
|
const acquiredTypes = ref<RawAcquiredType[]>([]);
|
||||||
|
|
||||||
const types = computed(() => acquiredTypes.value.filter(item => item.remaining > 0));
|
const types = computed(() => acquiredTypes.value.filter(item => item.remaining > 0));
|
||||||
const addAcquiredType = async (type: number, quantity: number, price: number, source?: AcquiredTypeSource) => {
|
|
||||||
const newItem = mapRawMarbasAcquiredType((await marbasAxiosInstance.post<RawMarbasAcquiredType, AxiosResponse<RawMarbasAcquiredType>, InsertableRawMarbasAcquiredType>(endpoint, {
|
|
||||||
type: type,
|
|
||||||
quantity: quantity,
|
|
||||||
remaining: quantity,
|
|
||||||
price: price,
|
|
||||||
source: source ?? 'misc',
|
|
||||||
})).data);
|
|
||||||
|
|
||||||
acquiredTypes.value = [...acquiredTypes.value, newItem];
|
const addAcquiredType = (type: number, quantity: number, price: number, ledgerId: string, datetime: Date) =>
|
||||||
log.info(`Acquired type ${newItem.id} with quantity ${newItem.quantity} and price ${newItem.price}`, newItem);
|
activityApi.acquire({
|
||||||
};
|
source: { type: 'LEDGER', characterId: null, ledgerId },
|
||||||
const removeAcquiredType = async (id: number, quantity: number) => {
|
marketTypeId: type,
|
||||||
const found = acquiredTypes.value.find(t => t.id === id);
|
quantity,
|
||||||
|
unitPrice: price,
|
||||||
|
datetime: datetime.toISOString(),
|
||||||
|
taxes: null,
|
||||||
|
description: null,
|
||||||
|
});
|
||||||
|
|
||||||
if (!found) {
|
const removeAcquiredType = (id: string, quantity: number, ledgerId: string, datetime: Date) => {
|
||||||
return 0;
|
const acquisition = acquiredTypes.value.find(a => a.id === id);
|
||||||
|
|
||||||
|
if (!acquisition) {
|
||||||
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = {
|
return activityApi.consume({
|
||||||
...found,
|
source: { type: 'LEDGER', characterId: null, ledgerId },
|
||||||
remaining: Math.max(0, found.remaining - quantity)
|
marketTypeId: acquisition.type,
|
||||||
};
|
quantity,
|
||||||
|
unitPrice: 0,
|
||||||
acquiredTypes.value = acquiredTypes.value.map(i => {
|
datetime: datetime.toISOString(),
|
||||||
if (i.id === item.id) {
|
taxes: null,
|
||||||
return item;
|
description: null,
|
||||||
} else {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
await marbasAxiosInstance.put(`${endpoint}${item.id}/`, item);
|
|
||||||
log.info(`Acquired type ${item.id} remaining: ${item.remaining}`, item);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const refresh = () => marbasAxiosInstance.get<RawMarbasAcquiredType[]>(endpoint).then(res => acquiredTypes.value = res.data.map(mapRawMarbasAcquiredType));
|
const refresh = () => acquisitionApi.findAllAcquisitions()
|
||||||
|
.then(response => acquiredTypes.value = response.data.map(toAcquiredType));
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
|
|
||||||
return { acquiredTypes: types, addAcquiredType, removeAcquiredType, refresh };
|
onActivitiesProcessed(refresh);
|
||||||
});
|
|
||||||
|
return { acquiredTypes: types, addAcquiredType, removeAcquiredType };
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * from './AcquiredType';
|
|||||||
export * from './acquisition';
|
export * from './acquisition';
|
||||||
|
|
||||||
export { default as AcquisitionResultTable } from './AcquisitionResultTable.vue';
|
export { default as AcquisitionResultTable } from './AcquisitionResultTable.vue';
|
||||||
|
export { default as AcquisitionsPanel } from './AcquisitionsPanel.vue';
|
||||||
export { default as BuyModal } from './BuyModal.vue';
|
export { default as BuyModal } from './BuyModal.vue';
|
||||||
export { default as SellModal } from './SellModal.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>
|
||||||
@@ -8,4 +8,4 @@ export type MarketTypePrice = {
|
|||||||
orderCount: number;
|
orderCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PriceGetter = (types: MarketType[]) => Promise<MarketTypePrice[]>;
|
export type PriceGetter = (types: MarketType[], locationId?: number) => Promise<MarketTypePrice[]>;
|
||||||
|
|||||||
@@ -1,30 +1,26 @@
|
|||||||
import { defineStore } from 'pinia';
|
import {defineStore} from 'pinia';
|
||||||
import { RegionalMarketCache } from '../RegionalMarketCache';
|
import {RegionalMarketCache} from '../RegionalMarketCache';
|
||||||
import { jitaId } from '../market';
|
import {jitaId} from '../market';
|
||||||
import { MarketType } from "../type";
|
import {MarketType} from "../type";
|
||||||
import { MarketTypePrice } from './MarketTypePrice';
|
import {MarketTypePrice} from './MarketTypePrice';
|
||||||
import { getEvepraisalPrices } from './evepraisal';
|
import {getMammonPrices} from './mammon';
|
||||||
import { getfuzzworkPrices } from './fuzzwork';
|
|
||||||
|
|
||||||
const cacheDuration = 1000 * 60 * 5; // 5 minutes
|
const CACHE_DURATION = 1000 * 60 * 5; // 5 minutes
|
||||||
const priceGetters = {
|
const BATCH_SIZE = 100;
|
||||||
evepraisal: getEvepraisalPrices,
|
|
||||||
fuzzwork: getfuzzworkPrices
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useApraisalStore = defineStore('appraisal', () => {
|
export const useAppraisalStore = defineStore('appraisal', () => {
|
||||||
const cache: RegionalMarketCache<MarketTypePrice> = new RegionalMarketCache(cacheDuration);
|
const cache: RegionalMarketCache<MarketTypePrice> = new RegionalMarketCache(CACHE_DURATION);
|
||||||
|
|
||||||
const getPricesUncached = priceGetters.fuzzwork;
|
const getPricesUncached = getMammonPrices;
|
||||||
|
|
||||||
const getPrice = async (type: MarketType, regionId?: number): Promise<MarketTypePrice> => (await getPrices([type], regionId))[0];
|
const getPrice = async (type: MarketType, locationId?: number): Promise<MarketTypePrice> => (await getPrices([type], locationId))[0];
|
||||||
const getPrices = async (types: MarketType[], regionId?: number): Promise<MarketTypePrice[]> => {
|
const getPrices = async (types: MarketType[], locationId?: number): Promise<MarketTypePrice[]> => {
|
||||||
const cached: MarketTypePrice[] = [];
|
const cached: MarketTypePrice[] = [];
|
||||||
const uncached: MarketType[] = [];
|
const uncached: MarketType[] = [];
|
||||||
const rId = regionId ?? jitaId;
|
const lId = locationId ?? jitaId;
|
||||||
|
|
||||||
types.forEach(t => {
|
types.forEach(t => {
|
||||||
const cachedPrice = cache.get(rId, t.id);
|
const cachedPrice = cache.get(lId, t.id);
|
||||||
|
|
||||||
if (cachedPrice) {
|
if (cachedPrice) {
|
||||||
cached.push(cachedPrice);
|
cached.push(cachedPrice);
|
||||||
@@ -34,9 +30,15 @@ export const useApraisalStore = defineStore('appraisal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (uncached.length > 0) {
|
if (uncached.length > 0) {
|
||||||
const prices = await getPricesUncached(uncached);
|
const batches: Promise<MarketTypePrice[]>[] = [];
|
||||||
|
|
||||||
prices.forEach(p => cache.set(rId, p.type.id, p));
|
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
|
||||||
|
batches.push(getPricesUncached(uncached.slice(i, i + BATCH_SIZE), lId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const prices = (await Promise.all(batches)).flat();
|
||||||
|
|
||||||
|
prices.forEach(p => cache.set(lId, p.type.id, p));
|
||||||
return [ ...cached, ...prices ];
|
return [ ...cached, ...prices ];
|
||||||
}
|
}
|
||||||
return cached;
|
return cached;
|
||||||
|
|||||||
@@ -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()];
|
||||||
|
};
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { logResource } from '@/service';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { MarketType } from "../type";
|
|
||||||
import { PriceGetter } from './MarketTypePrice';
|
|
||||||
|
|
||||||
export const evepraisalAxiosInstance = axios.create({
|
|
||||||
baseURL: import.meta.env.VITE_EVEPRAISAL_URL,
|
|
||||||
headers: {
|
|
||||||
'accept': 'application/json',
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
})
|
|
||||||
logResource(evepraisalAxiosInstance)
|
|
||||||
|
|
||||||
const batchSize = 100;
|
|
||||||
|
|
||||||
export const getEvepraisalPrices: PriceGetter = async types => {
|
|
||||||
const batches = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < types.length; i += batchSize) {
|
|
||||||
batches.push(evepraisalAxiosInstance.post(`/appraisal.json?market=jita&persist=no&raw_textarea=${types.slice(i, i + batchSize).map(t => t.name).join("%0A")}`));
|
|
||||||
}
|
|
||||||
return (await Promise.all(batches))
|
|
||||||
.flatMap(b => b.data.appraisal.items)
|
|
||||||
.map((item: any) => ({
|
|
||||||
type: types.find(t => t.name === item.typeName) as MarketType,
|
|
||||||
buy: item.prices.buy.max,
|
|
||||||
sell: item.prices.sell.min,
|
|
||||||
orderCount: item.prices.all.order_count
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { logResource } from '@/service';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { MarketType } from "../type";
|
|
||||||
import { PriceGetter } from './MarketTypePrice';
|
|
||||||
|
|
||||||
export const fuzzworkAxiosInstance = axios.create({
|
|
||||||
baseURL: import.meta.env.VITE_FUZZWORK_URL,
|
|
||||||
headers: {
|
|
||||||
'accept': 'application/json',
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
})
|
|
||||||
logResource(fuzzworkAxiosInstance)
|
|
||||||
|
|
||||||
const batchSize = 100;
|
|
||||||
|
|
||||||
export const getfuzzworkPrices: PriceGetter = async types => {
|
|
||||||
const batches = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < types.length; i += batchSize) {
|
|
||||||
batches.push(fuzzworkAxiosInstance.post(`/aggregates/?station=60003760&types=${types.slice(i, i + batchSize).map(t => t.id).join(",")}`));
|
|
||||||
}
|
|
||||||
return (await Promise.all(batches))
|
|
||||||
.flatMap(b => Object.entries(b.data))
|
|
||||||
.map(entry => {
|
|
||||||
const id = doParseInt(entry[0]);
|
|
||||||
const prices = entry[1] as any;
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: types.find(t => t.id === id) as MarketType,
|
|
||||||
buy: doParseFloat(prices?.buy?.max),
|
|
||||||
sell: doParseFloat(prices?.sell?.min),
|
|
||||||
orderCount: doParseInt(prices?.buy?.order_count) + doParseInt(prices?.sell?.order_count)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const doParseInt = (s?: string) => s ? parseInt(s) : 0;
|
|
||||||
const doParseFloat = (s?: string) => s ? parseFloat(s) : 0;
|
|
||||||
@@ -1,2 +1,7 @@
|
|||||||
export * from './MarketTypePrice';
|
export * from './MarketTypePrice';
|
||||||
export * from './appraisal';
|
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;
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { esiAxiosInstance } from "@/service";
|
|
||||||
import { RegionalMarketCache } from '../RegionalMarketCache';
|
|
||||||
import { jitaId } from "../market";
|
|
||||||
|
|
||||||
|
|
||||||
export type EsiMarketOrderHistory = {
|
|
||||||
average: number;
|
|
||||||
date: string;
|
|
||||||
highest: number;
|
|
||||||
lowest: number;
|
|
||||||
order_count: number;
|
|
||||||
volume: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO use pinia store
|
|
||||||
const historyCache: RegionalMarketCache<EsiMarketOrderHistory[]> = new RegionalMarketCache(() => {
|
|
||||||
const date = new Date();
|
|
||||||
|
|
||||||
if (date.getUTCHours() >= 11) {
|
|
||||||
date.setUTCDate(date.getUTCDate() + 1);
|
|
||||||
}
|
|
||||||
date.setUTCHours(11, 0, 0, 0);
|
|
||||||
return date;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getHistory = async (typeId: number, regionId?: number): Promise<EsiMarketOrderHistory[]> => {
|
|
||||||
const rId = regionId ?? jitaId;
|
|
||||||
|
|
||||||
return historyCache.computeIfAbsent(rId, typeId, async () => (await esiAxiosInstance.get(`/markets/${rId}/history/`, { params: { type_id: typeId } })).data);
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { EsiMarketOrderHistory } from "@/market";
|
|
||||||
|
|
||||||
export type HistoryQuartils = {
|
|
||||||
totalVolume: number,
|
|
||||||
q1: number,
|
|
||||||
median: number,
|
|
||||||
q3: number,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getHistoryQuartils = (history: EsiMarketOrderHistory[], 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: EsiMarketOrderHistory): number => {
|
|
||||||
if (history.volume === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return Math.max(1, Math.round(history.volume * ((history.average - history.lowest) / (history.highest - history.lowest))));
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,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;
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
export * from './EsiMarketOrderHistory';
|
export * from './history.ts';
|
||||||
export * from './HistoryQuartils';
|
|
||||||
|
export { default as MarketTrendIcon } from './MarketTrendIcon.vue';
|
||||||
|
export { default as TrendFilter } from './TrendFilter.vue';
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
export * from './RegionalMarketCache';
|
export * from './RegionalMarketCache';
|
||||||
export * from './history';
|
export * from './history';
|
||||||
|
export * from './location';
|
||||||
|
export * from './order';
|
||||||
export * from './tax';
|
export * from './tax';
|
||||||
export * from './type';
|
export * from './type';
|
||||||
|
|
||||||
export * from './appraisal';
|
export * from './appraisal';
|
||||||
export * from './market';
|
export * from './market';
|
||||||
|
|
||||||
|
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,2 @@
|
|||||||
|
export * from './MarketLocation';
|
||||||
|
export { default as MarketLocationInput } from './MarketLocationInput.vue';
|
||||||
@@ -1 +1 @@
|
|||||||
export const jitaId = 10000002;
|
export const jitaId = 60003760;
|
||||||
@@ -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,13 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { SliderCheckbox } from '@/components';
|
import {SliderCheckbox} from '@/components';
|
||||||
import { SortableHeader, useSort, VirtualScrollTable } from '@/components/table';
|
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
|
||||||
import { formatIsk, percentFormater } from '@/formaters';
|
import {formatIsk, percentFormater} from "@/formaters";
|
||||||
import { getHistoryQuartils, MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore } from "@/market";
|
import {MarketTrend, MarketTrendIcon, MarketTrends, MarketType, MarketTypeLabel, TrendFilter} from "@/market";
|
||||||
import { BookmarkSlashIcon, ShoppingCartIcon } from '@heroicons/vue/24/outline';
|
import {useStorage} from '@vueuse/core';
|
||||||
import { useStorage } from '@vueuse/core';
|
import {computed, ref} from 'vue';
|
||||||
import { computed, ref } from 'vue';
|
import {useAcquiredTypesStore} from '../acquisition';
|
||||||
import { useAcquiredTypesStore } from '../acquisition';
|
import {ScanResult} from './scan';
|
||||||
import { TrackingResult } from './tracking';
|
|
||||||
|
|
||||||
type Result = {
|
type Result = {
|
||||||
type: MarketType;
|
type: MarketType;
|
||||||
@@ -18,54 +17,49 @@ type Result = {
|
|||||||
q1: number;
|
q1: number;
|
||||||
median: number;
|
median: number;
|
||||||
q3: number;
|
q3: number;
|
||||||
|
totalVolume: number;
|
||||||
acquisitions: number;
|
acquisitions: number;
|
||||||
profit: number;
|
profit: number;
|
||||||
score: number;
|
score: number;
|
||||||
|
trend: MarketTrend;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items?: TrackingResult[];
|
items?: ScanResult[];
|
||||||
infoOnly?: boolean;
|
infoOnly?: boolean;
|
||||||
ignoredColums?: string[] | string;
|
ignoredColumns?: string[] | string;
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: 'buy', type: MarketType, buy: number, sell: number): void;
|
|
||||||
(e: 'remove', type: MarketType): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scoreFormater = new Intl.NumberFormat("en-US", {
|
const scoreFormater = new Intl.NumberFormat("en-US", {
|
||||||
maximumFractionDigits: 0
|
maximumFractionDigits: 0
|
||||||
});
|
});
|
||||||
|
const volumeFormater = new Intl.NumberFormat("en-US", {
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
});
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
items: () => [],
|
items: () => [],
|
||||||
infoOnly: false,
|
infoOnly: false,
|
||||||
ignoredColums: () => []
|
ignoredColumns: () => []
|
||||||
});
|
});
|
||||||
defineEmits<Emits>();
|
|
||||||
|
|
||||||
const marketTaxStore = useMarketTaxStore();
|
|
||||||
const acquiredTypesStore = useAcquiredTypesStore();
|
const acquiredTypesStore = useAcquiredTypesStore();
|
||||||
|
|
||||||
const days = useStorage('market-tracking-days', 365);
|
const threshold = useStorage('market-scan-threshold', 10);
|
||||||
const threshold = useStorage('market-tracking-threshold', 10);
|
|
||||||
const filter = ref("");
|
const filter = ref("");
|
||||||
const onlyCheap = ref(false);
|
const onlyCheap = ref(false);
|
||||||
const columnsToIgnore = computed(() => {
|
const trackedTrends = useStorage<MarketTrend[]>('market-scan-trends', [
|
||||||
const ic = typeof props.ignoredColums === 'string' ? [props.ignoredColums] : props.ignoredColums;
|
MarketTrends.Up,
|
||||||
|
MarketTrends.Flat,
|
||||||
if (props.infoOnly && !ic.includes('buttons')) {
|
MarketTrends.Down,
|
||||||
return [...ic, 'buttons'];
|
]);
|
||||||
}
|
const columnsToIgnore = computed(() =>
|
||||||
return ic;
|
typeof props.ignoredColumns === 'string' ? [props.ignoredColumns] : props.ignoredColumns
|
||||||
});
|
);
|
||||||
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => props.items
|
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => props.items
|
||||||
.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase()))
|
.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase()))
|
||||||
|
.filter(r => trackedTrends.value.includes(r.trend))
|
||||||
.map(r => {
|
.map(r => {
|
||||||
const quartils = getHistoryQuartils(r.history, days.value);
|
|
||||||
const profit = quartils.q1 === 0 || quartils.q3 === 0 ? 0 : marketTaxStore.calculateProfit(quartils.q1, quartils.q3);
|
|
||||||
const score = profit <= 0 ? 0 : Math.sqrt((Math.pow(quartils.totalVolume, 1.1) * Math.pow(quartils.q1, 1.2) * Math.pow(profit, 0.5) * Math.pow(Math.max(1, r.orderCount), -0.7)) / days.value);
|
|
||||||
const acquisitions = columnsToIgnore.value.includes('acquisitions') ? 0 : acquiredTypesStore.acquiredTypes
|
const acquisitions = columnsToIgnore.value.includes('acquisitions') ? 0 : acquiredTypesStore.acquiredTypes
|
||||||
.filter(t => t.type === r.type.id)
|
.filter(t => t.type === r.type.id)
|
||||||
.reduce((a, b) => a + b.remaining, 0);
|
.reduce((a, b) => a + b.remaining, 0);
|
||||||
@@ -76,17 +70,19 @@ const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() =>
|
|||||||
name: r.type.name,
|
name: r.type.name,
|
||||||
buy: r.buy,
|
buy: r.buy,
|
||||||
sell: r.sell,
|
sell: r.sell,
|
||||||
q1: quartils.q1,
|
q1: r.q1,
|
||||||
median: quartils.median,
|
median: r.median,
|
||||||
q3: quartils.q3,
|
q3: r.q3,
|
||||||
|
totalVolume: r.totalVolume,
|
||||||
acquisitions,
|
acquisitions,
|
||||||
profit,
|
profit: r.profit,
|
||||||
score
|
score: r.score,
|
||||||
|
trend: r.trend,
|
||||||
};
|
};
|
||||||
}).filter(r => !onlyCheap.value || (r.buy <= r.q1 && r.profit >= (threshold.value / 100)))), {
|
}).filter(r => !onlyCheap.value || (r.buy <= r.q1 && r.profit >= (threshold.value / 100)))), {
|
||||||
defaultSortKey: 'score',
|
defaultSortKey: 'score',
|
||||||
defaultSortDirection: 'desc',
|
defaultSortDirection: 'desc',
|
||||||
ignoredColums: columnsToIgnore
|
ignoredColumns: columnsToIgnore
|
||||||
})
|
})
|
||||||
const getLineColor = (result: Result) => {
|
const getLineColor = (result: Result) => {
|
||||||
if (props.infoOnly) {
|
if (props.infoOnly) {
|
||||||
@@ -105,18 +101,16 @@ const getLineColor = (result: Result) => {
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="!infoOnly" class="flex mb-2 mt-4">
|
<div v-if="!infoOnly" class="flex mb-2 mt-4">
|
||||||
<div class="flex justify-self-end ms-auto">
|
<div class="flex justify-self-end ms-auto">
|
||||||
<TaxInput />
|
|
||||||
<div class="end">
|
<div class="end">
|
||||||
<span>Profit Threshold: </span>
|
<span>Profit Threshold: </span>
|
||||||
<input type="number" min="0" max="1000" step="1" v-model="threshold" />
|
<input type="number" min="0" max="1000" step="1" v-model="threshold" />
|
||||||
</div>
|
</div>
|
||||||
<div class="end">
|
|
||||||
<span>Days: </span>
|
|
||||||
<input type="number" min="1" max="365" step="1" v-model="days" />
|
|
||||||
</div>
|
|
||||||
<div class="end flex">
|
<div class="end flex">
|
||||||
<SliderCheckbox class="me-1" v-model="onlyCheap" /> Show only cheap items
|
<SliderCheckbox class="me-1" v-model="onlyCheap" /> Show only cheap items
|
||||||
</div>
|
</div>
|
||||||
|
<div class="end flex items-center">
|
||||||
|
<TrendFilter v-model="trackedTrends" />
|
||||||
|
</div>
|
||||||
<div class="end">
|
<div class="end">
|
||||||
<span>Filter: </span>
|
<span>Filter: </span>
|
||||||
<input type="search" class="w-96" v-model="filter" />
|
<input type="search" class="w-96" v-model="filter" />
|
||||||
@@ -133,29 +127,29 @@ const getLineColor = (result: Result) => {
|
|||||||
<SortableHeader v-bind="headerProps" sortKey="q1">Q1</SortableHeader>
|
<SortableHeader v-bind="headerProps" sortKey="q1">Q1</SortableHeader>
|
||||||
<SortableHeader v-bind="headerProps" sortKey="median">Median</SortableHeader>
|
<SortableHeader v-bind="headerProps" sortKey="median">Median</SortableHeader>
|
||||||
<SortableHeader v-bind="headerProps" sortKey="q3">Q3</SortableHeader>
|
<SortableHeader v-bind="headerProps" sortKey="q3">Q3</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="totalVolume">Volume</SortableHeader>
|
||||||
<SortableHeader v-bind="headerProps" sortKey="profit">Profit</SortableHeader>
|
<SortableHeader v-bind="headerProps" sortKey="profit">Profit</SortableHeader>
|
||||||
<SortableHeader v-bind="headerProps" sortKey="score">Score</SortableHeader>
|
<SortableHeader v-bind="headerProps" sortKey="score">Score</SortableHeader>
|
||||||
<SortableHeader v-bind="headerProps" sortKey="acquisitions">Acquisitions</SortableHeader>
|
<SortableHeader v-bind="headerProps" sortKey="acquisitions">Acquisitions</SortableHeader>
|
||||||
<SortableHeader v-bind="headerProps" sortKey="buttons" unsortable />
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="r in list" :key="r.data.typeID" :class="getLineColor(r.data)">
|
<tr v-for="r in list" :key="r.data.typeID" :class="getLineColor(r.data)">
|
||||||
<td v-if="showColumn('name')">
|
<td v-if="showColumn('name')">
|
||||||
|
<div class="flex">
|
||||||
<MarketTypeLabel :id="r.data.typeID" :name="r.data.name" />
|
<MarketTypeLabel :id="r.data.typeID" :name="r.data.name" />
|
||||||
|
<MarketTrendIcon class="ms-auto" :trend="r.data.trend" />
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td v-if="showColumn('buy')" class="text-right">{{ formatIsk(r.data.buy) }}</td>
|
<td v-if="showColumn('buy')" class="text-right">{{ formatIsk(r.data.buy) }}</td>
|
||||||
<td v-if="showColumn('sell')" class="text-right">{{ formatIsk(r.data.sell) }}</td>
|
<td v-if="showColumn('sell')" class="text-right">{{ formatIsk(r.data.sell) }}</td>
|
||||||
<td v-if="showColumn('q1')" class="text-right">{{ formatIsk(r.data.q1) }}</td>
|
<td v-if="showColumn('q1')" class="text-right">{{ formatIsk(r.data.q1) }}</td>
|
||||||
<td v-if="showColumn('median')" class="text-right">{{ formatIsk(r.data.median) }}</td>
|
<td v-if="showColumn('median')" class="text-right">{{ formatIsk(r.data.median) }}</td>
|
||||||
<td v-if="showColumn('q3')" class="text-right">{{ formatIsk(r.data.q3) }}</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('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('score')" class="text-right">{{ scoreFormater.format(r.data.score) }}</td>
|
||||||
<td v-if="showColumn('acquisitions')" class="text-right">{{ r.data.acquisitions }}</td>
|
<td v-if="showColumn('acquisitions')" class="text-right">{{ r.data.acquisitions }}</td>
|
||||||
<td v-if="showColumn('buttons')" class="text-right">
|
|
||||||
<button class="btn-icon me-1" title="Add acquisitions" @click="$emit('buy', r.data.type, r.data.buy, r.data.sell)"><ShoppingCartIcon /></button>
|
|
||||||
<button class="btn-icon me-1" title="Untrack" @click="$emit('remove', r.data.type)"><BookmarkSlashIcon /></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</template>
|
</template>
|
||||||
@@ -167,8 +161,9 @@ const getLineColor = (result: Result) => {
|
|||||||
</VirtualScrollTable>
|
</VirtualScrollTable>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
div.end {
|
div.end {
|
||||||
@apply justify-self-end ms-2;
|
@apply justify-self-end ms-2;
|
||||||
}
|
}
|
||||||
</style>../history/HistoryQuartils
|
</style>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './scan';
|
||||||
|
|
||||||
|
export { default as ScanResultTable } from './ScanResultTable.vue';
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import {MarketTrend, MarketType} from "@/market";
|
||||||
|
import {MarketScanResponse} from "@/generated/mammon";
|
||||||
|
|
||||||
|
export type ScanResult = {
|
||||||
|
type: MarketType;
|
||||||
|
buy: number;
|
||||||
|
sell: number;
|
||||||
|
q1: number;
|
||||||
|
median: number;
|
||||||
|
q3: number;
|
||||||
|
totalVolume: number;
|
||||||
|
profit: number;
|
||||||
|
score: number;
|
||||||
|
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,
|
||||||
|
});
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from "pinia";
|
import {useMarketTaxStore} from "./tax";
|
||||||
import { useMarketTaxStore } from "./tax";
|
|
||||||
|
|
||||||
const { brokerFee, scc } = storeToRefs(useMarketTaxStore());
|
const marketTaxStore = useMarketTaxStore();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="end">
|
<div class="end">
|
||||||
<span>Broker Fee: </span>
|
<span>Broker Fee: </span>
|
||||||
<input type="number" min="1" max="3" step="0.01" v-model="brokerFee" />
|
<input type="number" min="1" max="3" step="0.01" v-model="marketTaxStore.brokerFee" />
|
||||||
</div>
|
</div>
|
||||||
<div class="end">
|
<div class="end">
|
||||||
<span>SCC: </span>
|
<span>SCC: </span>
|
||||||
<input type="number" min="3.6" max="8" step="0.01" v-model="scc" >
|
<input type="number" min="3.375" max="8" step="0.01" v-model="marketTaxStore.scc" >
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
div.end {
|
div.end {
|
||||||
@apply justify-self-end ms-2;
|
@apply justify-self-end ms-2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { defineStore } from "pinia";
|
|||||||
|
|
||||||
export const useMarketTaxStore = defineStore("marketTax", () => {
|
export const useMarketTaxStore = defineStore("marketTax", () => {
|
||||||
const brokerFee = useLocalStorage("market-brokerFee", 1.5);
|
const brokerFee = useLocalStorage("market-brokerFee", 1.5);
|
||||||
const scc = useLocalStorage("market-scc", 3.6);
|
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 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;
|
const calculateProfit = (buy: number, sell: number) => (applyTaxes(sell, true) / applyTaxes(buy)) - 1;
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export * from './tracking';
|
|
||||||
|
|
||||||
export { default as TrackingResultTable } from './TrackingResultTable.vue';
|
|
||||||
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { marbasAxiosInstance, MarbasObject } from "@/marbas";
|
|
||||||
import { EsiMarketOrderHistory, getHistory, MarketType, MarketTypePrice } from "@/market";
|
|
||||||
import log from "loglevel";
|
|
||||||
import { defineStore } from "pinia";
|
|
||||||
import { computed, ref } from "vue";
|
|
||||||
|
|
||||||
export type TrackingResult = {
|
|
||||||
type: MarketType;
|
|
||||||
history: EsiMarketOrderHistory[];
|
|
||||||
buy: number,
|
|
||||||
sell: number,
|
|
||||||
orderCount: number,
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MarbasTrackedType = MarbasObject & {
|
|
||||||
type: number
|
|
||||||
};
|
|
||||||
|
|
||||||
const endpoint = '/api/types_tracking/';
|
|
||||||
|
|
||||||
export const useMarketTrackingStore = defineStore('marketTracking', () => {
|
|
||||||
const trackedTypes = ref<MarbasTrackedType[]>([]);
|
|
||||||
|
|
||||||
const types = computed(() => trackedTypes.value.map(item => item.type) ?? []);
|
|
||||||
const addType = async (type: number) => {
|
|
||||||
const found = trackedTypes.value.find(item => item.type === type);
|
|
||||||
|
|
||||||
if (!found) {
|
|
||||||
trackedTypes.value = [...trackedTypes.value, (await marbasAxiosInstance.post<MarbasTrackedType>(endpoint, { type })).data];
|
|
||||||
log.info(`Tracking type ${type}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const removeType = async (type: number) => {
|
|
||||||
const found = trackedTypes.value.find(item => item.type === type);
|
|
||||||
|
|
||||||
if (!found) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
trackedTypes.value = trackedTypes.value.filter(t => t.id !== found.id);
|
|
||||||
await marbasAxiosInstance.delete(`${endpoint}${found.id}`);
|
|
||||||
log.info(`Stopped tracking type ${type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
marbasAxiosInstance.get<MarbasTrackedType[]>(endpoint).then(res => trackedTypes.value = res.data);
|
|
||||||
|
|
||||||
return { types, addType, removeType };
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createResult = async (id: number, price: MarketTypePrice): Promise<TrackingResult> => ({ history: await getHistory(id), ...price });
|
|
||||||
@@ -1,57 +1,40 @@
|
|||||||
import { marbasAxiosInstance } from "@/marbas";
|
import {marketApi} from '@/mammon/mammonService';
|
||||||
|
import type {MarketTypeResponse} from '@/generated/mammon';
|
||||||
|
|
||||||
export type MarketType = {
|
export type MarketType = MarketTypeResponse;
|
||||||
id: number;
|
|
||||||
group_id: number;
|
const cache = new Map<number, MarketType>(); // TODO move to pinia store
|
||||||
marketgroup_id: number;
|
|
||||||
name: string;
|
const BATCH_SIZE = 100;
|
||||||
published: boolean;
|
|
||||||
description: string;
|
const fetchTypes = async (ids: number[]): Promise<void> => {
|
||||||
basePrice: number;
|
const missing = ids.filter(id => !cache.has(id));
|
||||||
icon_id: number;
|
if (missing.length === 0) {
|
||||||
volume: number;
|
return;
|
||||||
portionSize: number;
|
}
|
||||||
}
|
const batches: Promise<MarketType[]>[] = [];
|
||||||
|
for (let i = 0; i < missing.length; i += BATCH_SIZE) {
|
||||||
|
batches.push(marketApi.findTypes(missing.slice(i, i + BATCH_SIZE)).then(r => r.data));
|
||||||
|
}
|
||||||
|
const results = await Promise.all(batches);
|
||||||
|
results.flat().forEach(t => cache.set(t.id, t));
|
||||||
|
};
|
||||||
|
|
||||||
export const getMarketType = async (type: string | number): Promise<MarketType> => (await getMarketTypes([type]))[0];
|
export const getMarketType = async (type: string | number): Promise<MarketType> => (await getMarketTypes([type]))[0];
|
||||||
export const getMarketTypes = async (types: (string | number)[]): Promise<MarketType[]> => {
|
export const getMarketTypes = async (types: (string | number)[]): Promise<MarketType[]> => {
|
||||||
if (types.length === 0) {
|
if (types.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
} else if (types.length === 1 && typeof types[0] === "number") {
|
|
||||||
return [(await marbasAxiosInstance.get<MarketType>(`/sde/types/${types[0]}/`)).data];
|
|
||||||
}
|
}
|
||||||
return (await marbasAxiosInstance.post<MarketType[]>("/api/types/search", types.map(t => {
|
const ids = types.filter((t): t is number => typeof t === 'number');
|
||||||
if (typeof t === "number") {
|
await fetchTypes(ids);
|
||||||
return { id: t };
|
return ids.map(id => cache.get(id)).filter((t): t is MarketType => t !== undefined);
|
||||||
} else {
|
|
||||||
return { name: t };
|
|
||||||
}
|
|
||||||
}))).data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const blueprintMarketGrous = [ // TODO add all groups
|
|
||||||
2,
|
|
||||||
2157,
|
|
||||||
2159,
|
|
||||||
2339,
|
|
||||||
2160,
|
|
||||||
211,
|
|
||||||
1016,
|
|
||||||
339,
|
|
||||||
2290,
|
|
||||||
357,
|
|
||||||
1530,
|
|
||||||
359,
|
|
||||||
1531,
|
|
||||||
1532,
|
|
||||||
1533,
|
|
||||||
358
|
|
||||||
]
|
|
||||||
|
|
||||||
export const searchMarketTypes = async (search: string): Promise<MarketType[]> => {
|
export const searchMarketTypes = async (search: string): Promise<MarketType[]> => {
|
||||||
return (await marbasAxiosInstance.post<MarketType[]>("/api/types/search", [{
|
if (search.length === 0) {
|
||||||
name__icontains: search,
|
return [];
|
||||||
marketgroup_id___not: null,
|
}
|
||||||
marketgroup_id__in___not: blueprintMarketGrous,
|
const types = await marketApi.searchTypes(search).then(r => r.data);
|
||||||
}])).data;
|
types.forEach(t => cache.set(t.id, t));
|
||||||
|
return types;
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import {vOnClickOutside} from '@vueuse/components';
|
||||||
import { useVirtualList } from '@vueuse/core';
|
import {useVirtualList} from '@vueuse/core';
|
||||||
import log from 'loglevel';
|
import log from 'loglevel';
|
||||||
import { nextTick, ref, watch, watchEffect } from 'vue';
|
import {nextTick, ref, watch, watchEffect} from 'vue';
|
||||||
import { MarketType, searchMarketTypes } from './MarketType';
|
import {MarketType, searchMarketTypes} from './MarketType';
|
||||||
import MarketTypeLabel from "./MarketTypeLabel.vue";
|
import MarketTypeLabel from "./MarketTypeLabel.vue";
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
@@ -104,7 +104,8 @@ watchEffect(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
.fake-input {
|
.fake-input {
|
||||||
@apply w-96 flex border bg-slate-500 rounded px-1 py-0.5;
|
@apply w-96 flex border bg-slate-500 rounded px-1 py-0.5;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ClipboardButton } from '@/components';
|
import {ClipboardButton} from '@/components';
|
||||||
import { InformationCircleIcon } from '@heroicons/vue/24/outline';
|
import {InformationCircleIcon} from '@heroicons/vue/24/outline';
|
||||||
|
import {routeNames} from '@/routes';
|
||||||
|
import {computedAsync} from "@vueuse/core";
|
||||||
|
import {getMarketType} from "@/market";
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -9,28 +12,39 @@ interface Props {
|
|||||||
hideCopy?: boolean;
|
hideCopy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
name: "",
|
name: "",
|
||||||
id: 0,
|
id: 0,
|
||||||
hideCopy: false
|
hideCopy: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const computedName = computedAsync<string>(async () => {
|
||||||
|
if (props.name) {
|
||||||
|
return props.name;
|
||||||
|
} else if (props.id) {
|
||||||
|
return await getMarketType(props.id).then(marketType => marketType.name);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}, "");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="id || name" class="flex flex-row">
|
<div v-if="id || computedName" class="flex flex-row">
|
||||||
<img v-if="id" :src="`https://images.evetech.net/types/${id}/icon?size=32`" class="inline-block w-5 h-5 me-1 mt-1" alt="" />
|
<img v-if="id" :src="`https://images.evetech.net/types/${id}/icon?size=32`" class="inline-block w-5 h-5 me-1 mt-1" alt="" />
|
||||||
<template v-if="name">
|
<template v-if="computedName">
|
||||||
{{ name }}
|
{{ computedName }}
|
||||||
<RouterLink v-if="id" :to="{ name: 'market-types', params: { type: id } }" class="button btn-icon ms-1 me-1 mt-1" title="Show item info">
|
<RouterLink v-if="id" :to="{ name: routeNames.marketTypes, params: { type: id } }" class="btn-icon ms-1 me-1 mt-1" title="Show item info">
|
||||||
<InformationCircleIcon />
|
<InformationCircleIcon />
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<ClipboardButton v-if="!hideCopy" :value="name" />
|
<ClipboardButton v-if="!hideCopy" :value="computedName" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped>
|
||||||
button:deep(>svg), .button:deep(>svg) {
|
@reference "@/style.css";
|
||||||
|
|
||||||
|
button:deep(>svg), .btn-icon:deep(>svg) {
|
||||||
@apply !w-4 !h-4;
|
@apply !w-4 !h-4;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,11 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const addCharacter = () => {
|
|
||||||
// TODO
|
import {CharacterLabel, useCharactersStore} from "@/characters";
|
||||||
}
|
import {useAuthStore} from "@/auth";
|
||||||
|
|
||||||
|
const charactersStore = useCharactersStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const addCharacter = () => authStore.addCharacter();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="grid mb-2 mt-4">
|
<div class="grid mb-2 mt-4">
|
||||||
<button class="justify-self-end" @click="addCharacter">Add chacarcter</button>
|
<div class="mb-4 border-b-1 flex justify-end">
|
||||||
|
<button class="mb-2" @click="addCharacter">Add chacarcter</button>
|
||||||
|
</div>
|
||||||
|
<div v-for="character in charactersStore.characters" :key="character.characterId" class="flex items-center mb-2">
|
||||||
|
<CharacterLabel class="grow" :character="character" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
+9
-1
@@ -1,6 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import {useAuthStore} from '@/auth';
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div></div>
|
<div class="flex flex-col items-center justify-center h-screen gap-4">
|
||||||
|
<h1 class="text-2xl">eveal</h1>
|
||||||
|
<button v-if="!authStore.isAuthenticated" @click="authStore.login()">
|
||||||
|
Login with EVE Online
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {RouterView} from 'vue-router';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
+12
-15
@@ -1,30 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink, RouterView } from 'vue-router';
|
import {RouterLink, RouterView} from 'vue-router';
|
||||||
|
import {routeNames} from '@/routes';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<div class="flex border-b-2 border-emerald-500">
|
<div class="flex border-b-2 border-emerald-500">
|
||||||
<RouterLink :to="{name: 'market-types'}" class="tab">
|
<RouterLink :to="{name: routeNames.marketTypes}" class="tab">
|
||||||
<span>Item Info</span>
|
<span>Item Info</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink to="/market/tracking" class="tab">
|
<RouterLink to="/market/scan" class="tab">
|
||||||
<span>Tracking</span>
|
<span>Scan</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink to="/market/acquisitions" class="tab">
|
<RouterLink to="/market/acquisitions" class="tab">
|
||||||
<span>Acquisitions</span>
|
<span>Acquisitions</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink :to="{name: routeNames.appraise}" class="tab">
|
||||||
|
<span>Appraisal</span>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink :to="{name: routeNames.compareAppraisal}" class="tab">
|
||||||
|
<span>Compare</span>
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
|
||||||
a.tab {
|
|
||||||
@apply flex items-center px-4 me-2 rounded-t-md bg-slate-600 hover:bg-slate-700;
|
|
||||||
&.router-link-active {
|
|
||||||
@apply bg-emerald-500 hover:bg-emerald-700;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
+16
-11
@@ -1,31 +1,36 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ReprocessInput, ReprocessItemValues, ReprocessResultTable, reprocess } from '@/reprocess';
|
import { useCharactersStore } from '@/characters';
|
||||||
|
import { ReprocessItemValues, ReprocessResultTable, reprocess } from '@/reprocess';
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
const items = ref("");
|
const charactersStore = useCharactersStore();
|
||||||
const materials = ref("");
|
|
||||||
const efficiency = useStorage('reprocess-efficiency', 0.55);
|
const characterId = useStorage<number | undefined>('reprocess-character', undefined);
|
||||||
|
const items = ref('');
|
||||||
|
|
||||||
const result = ref<ReprocessItemValues[]>([]);
|
const result = ref<ReprocessItemValues[]>([]);
|
||||||
|
|
||||||
const send = async () => result.value = await reprocess(items.value, materials.value, efficiency.value);
|
const send = async () => result.value = await reprocess(characterId.value, items.value);
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="grid mb-2 mt-4">
|
<div class="grid mb-2 mt-4">
|
||||||
<div class="justify-self-end">
|
<div class="justify-self-end">
|
||||||
<span>Reprocess efficiency: </span>
|
<span>Character: </span>
|
||||||
<input type="number" min="0" max="1" step="0.05" v-model="efficiency" />
|
<select v-model="characterId">
|
||||||
|
<option v-for="character in charactersStore.characters" :key="character.characterId" :value="character.characterId">{{ character.name }}</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-stretch">
|
<div class="flex items-stretch">
|
||||||
<ReprocessInput name="Item JSON" v-model="items" />
|
<div class="flex-1 mx-1">
|
||||||
<ReprocessInput name="Materials JSON" v-model="materials" />
|
<span>Items</span>
|
||||||
|
<textarea class="mt-1" v-model="items" placeholder="Paste an EVE inventory listing" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid my-2">
|
<div class="grid my-2">
|
||||||
<button class="justify-self-end" @click="send">Send</button>
|
<button class="justify-self-end" @click="send" :disabled="!characterId || !items.trim()">Send</button>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="result.length > 0">
|
<template v-if="result.length > 0">
|
||||||
<hr />
|
<hr />
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user