12 Commits

Author SHA1 Message Date
Sirttas 2143fc83f1 transition 2024-05-17 10:55:46 +02:00
Sirttas fa664ecd1b add alt 2024-05-16 22:22:28 +02:00
Sirttas 4df171e6f6 annimation 2024-05-16 12:02:30 +02:00
Sirttas 6d67e92749 User dropdown
User id in acquisition
about
2024-05-16 10:41:12 +02:00
Sirttas 0e4f1103d4 fix forgotten file 2024-05-16 08:54:42 +02:00
Sirttas 584dcaa4fd cleanup 2024-05-16 08:52:44 +02:00
Sirttas 61e1227a1a cleanup 2024-05-15 16:44:14 +02:00
Sirttas d6b51ff1a7 auth persistance 2024-05-15 16:43:52 +02:00
Sirttas 4012dded66 cleanup routes 2024-05-15 16:11:17 +02:00
Sirttas 2eea436641 rework login to wor with authentik
rework env handling a runtime
remove oketbase
2024-05-15 16:00:56 +02:00
Sirttas 206bdd0e55 don't delete acquisitions 2024-05-14 21:21:17 +02:00
Sirttas 7332e145f4 draft acquisition 2024-05-14 21:21:17 +02:00
106 changed files with 3291 additions and 9121 deletions
-2
View File
@@ -23,5 +23,3 @@ docker-compose.yml
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
generated/mammon/
+1 -2
View File
@@ -8,6 +8,5 @@ RUN npm run build
FROM nginx:alpine FROM nginx:alpine
ENV NGINX_ENVSUBST_OUTPUT_DIR=/usr/share/nginx/html ENV NGINX_ENVSUBST_OUTPUT_DIR=/usr/share/nginx/html
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
RUN mkdir etc/nginx/templates && \ RUN mv -rf /usr/share/nginx/html/index.html /etc/nginx/templates/index.html.template
mv /usr/share/nginx/html/index.html /etc/nginx/templates/index.html.template
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
-1195
View File
File diff suppressed because it is too large Load Diff
+2281 -2385
View File
File diff suppressed because it is too large Load Diff
+16 -20
View File
@@ -6,34 +6,30 @@
"scripts": { "scripts": {
"dev": "vite --host --debug", "dev": "vite --host --debug",
"build": "vue-tsc && vite build", "build": "vue-tsc && vite build",
"preview": "vite preview", "preview": "vite preview"
"test": "vitest"
}, },
"dependencies": { "dependencies": {
"@heroicons/vue": "^2.0.18", "@heroicons/vue": "^2.0.18",
"@vueuse/components": "^14.3.0", "@vueuse/components": "^10.5.0",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^10.2.1",
"@vueuse/integrations": "^14.3.0", "@vueuse/integrations": "^10.2.1",
"@vueuse/router": "^14.3.0",
"axios": "^1.4.0", "axios": "^1.4.0",
"gemory": "file:",
"loglevel": "^1.8.1", "loglevel": "^1.8.1",
"loglevel-plugin-prefix": "^0.8.4", "loglevel-plugin-prefix": "^0.8.4",
"monaco-editor": "^0.55.1", "oidc-client-ts": "^3.0.1",
"pinia": "^3.0.4", "pinia": "^2.1.6",
"sortablejs": "^1.15.7",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-router": "^5.0.7" "vue-router": "^4.2.4"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.0", "@types/node": "^20.4.5",
"@types/node": "^25.8.0", "@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue": "^6.0.7", "autoprefixer": "^10.4.14",
"tailwindcss": "^4.3.0", "postcss": "^8.4.27",
"typescript": "^6.0.3", "tailwindcss": "^3.3.3",
"vite": "^8.0.13", "typescript": "^5.0.2",
"vite-plugin-runtime-env": "^1.0.0", "vite": "^5.2.11",
"vitest": "^4.1.6", "vite-plugin-runtime-env": "^0.1.1",
"vue-tsc": "^3.2.9" "vue-tsc": "^2.0.18"
} }
} }
+8
View File
@@ -0,0 +1,8 @@
export default {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': {},
tailwindcss: {},
autoprefixer: {},
},
}
+7 -10
View File
@@ -1,14 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import {computed} from 'vue'; import { useAuthStore } from '@/auth';
import {RouterView, useRoute} from 'vue-router'; import { computed } from 'vue';
import {Sidebar} from './sidebar'; import { RouterView, useRoute } from 'vue-router';
import {ConfirmModal} from '@/confirm'; import { Sidebar } from './sidebar';
import {routeNames} from '@/routes';
const route = useRoute(); const route = useRoute();
const authStore = useAuthStore();
const hideSidebar = computed(() => { const hideSidebar = computed(() => {
return route.name === routeNames.callback || route.name === routeNames.about; return !authStore.isLoggedIn || route.name === 'callback' || route.name === 'about';
}); });
</script> </script>
@@ -22,12 +22,9 @@ const hideSidebar = computed(() => {
<RouterView /> <RouterView />
</div> </div>
</template> </template>
<ConfirmModal />
</template> </template>
<style scoped> <style scoped lang="postcss">
@reference "@/style.css";
div.main-container { div.main-container {
@apply px-4 sm:ml-64; @apply px-4 sm:ml-64;
} }
+47
View File
@@ -0,0 +1,47 @@
import log from "loglevel";
import { Log, User, UserManager } 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
});
const user = ref<User>();
const isLoggedIn = computed(() => !!user.value);
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 };
});
-20
View File
@@ -1,20 +0,0 @@
<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>
-28
View File
@@ -1,28 +0,0 @@
import {activityApi, characterApi} from "@/mammon";
import {defineStore} from "pinia";
import {ref} from "vue";
import {CharacterResponse} from "@/generated/mammon";
export type Character = CharacterResponse
export const useCharactersStore = defineStore('characters', () => {
const characters = ref<Character[]>([]);
const findById = async (characterId: number): Promise<Character | undefined> => {
let character = characters.value.find(c => c.characterId === characterId);
if (!character) {
await refresh(); // TODO call api instead of refresh
character = characters.value.find(c => c.characterId === characterId);
}
return character;
}
const reloadActivities = (characterId: number): Promise<void> => activityApi.fetchNewActivitiesForCharacter(characterId) as Promise<void>;
const refresh = () => characterApi.findAllCharacters().then(response => characters.value = response.data);
refresh();
return {characters, findById, reloadActivities, refresh};
})
-3
View File
@@ -1,3 +0,0 @@
export * from './chartacters.ts'
export {default as CharacterLabel} from './CharacterLabel.vue';
+1 -1
View File
@@ -18,6 +18,6 @@ const doCopy = () => {
<template> <template>
<button class="btn-icon" title="Copy to clipboard" @click="doCopy"> <button class="btn-icon" title="Copy to clipboard" @click="doCopy">
<ClipboardIcon /> <ClipboardIcon />
</button> </button>
</template> </template>
+31 -68
View File
@@ -1,96 +1,59 @@
<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 {useElementBounding, useEventListener} from '@vueuse/core'; import { useEventListener } from '@vueuse/core';
import {computed, ref} from 'vue'; import { ref } from 'vue';
interface Props {
inline?: boolean;
autoClose?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
inline: false,
autoClose: true
})
const isOpen = ref(false); const isOpen = ref(false);
const root = ref<HTMLElement | null>(null);
const floating = ref<HTMLElement | null>(null);
const { left, bottom, width } = useElementBounding(root);
const floatingStyle = computed(() => ({
left: `${left.value}px`,
top: `${bottom.value}px`,
minWidth: `${width.value}px`,
}));
const doAutoClose = () => {
if (props.autoClose) {
isOpen.value = false;
}
}
useEventListener('keyup', e => { useEventListener('keyup', e => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
doAutoClose(); isOpen.value = false;
} }
}); });
</script> </script>
<template> <template>
<div ref="root" class="dropdown" :class="{'dropdown-open': isOpen, 'dropdown-close': !isOpen}" v-on-click-outside="[doAutoClose, { ignore: [floating] }]"> <div class="dropdown" :class="{'dropdown-open': isOpen, 'dropdown-close': !isOpen}" v-on-click-outside="() => isOpen = false">
<button @click="isOpen = !isOpen" class="cursor-pointer"> <button @click="isOpen = !isOpen">
<Transition <slot name="button" />
enter-active-class="transition-transform" <Transition name="flip">
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 <Transition name="fade">
enter-active-class="transition-opacity" <div v-if="isOpen" class="relative">
enter-from-class="opacity-0" <div class="z-10 divide-y rounded-b-md absolute">
leave-from-class="transition-opacity" <slot />
leave-to-class="opacity-0"> </div>
<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> <style scoped lang="postcss">
@reference "@/style.css";
.chevron { .chevron {
@apply w-4 h-4 me-1; @apply w-4 h-4 ms-1;
} }
.dropdown-floating { .fade-enter-from, .fade-leave-to {
@apply fixed z-10; opacity: 0;
} }
.dropdown-floating > div { .fade-enter-active, .fade-leave-active {
@apply bg-slate-800 rounded-b-md shadow-lg; @apply transition-opacity;
}
.flip-enter-from, .flip-leave-to {
transform: rotate(180deg);
}
.flip-enter-active {
@apply transition-transform;
}
.flip-leave-active {
display: none;
} }
</style> </style>
+26 -12
View File
@@ -1,11 +1,24 @@
<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, useVModel } from '@vueuse/core';
import {watch} from 'vue'; import { watch } from 'vue';
const open = defineModel('open', { default: false }); interface Props {
open: boolean;
}
watch(open, value => { interface Emit {
(e: 'update:open', value: boolean): void;
}
const props = withDefaults(defineProps<Props>(), {
open: false,
});
const emit = defineEmits<Emit>();
const isOpen = useVModel(props, 'open', emit, {passive: true});
watch(isOpen, value => {
if (value) { if (value) {
document.body.classList.add('overflow-hidden'); document.body.classList.add('overflow-hidden');
} else { } else {
@@ -14,18 +27,18 @@ watch(open, value => {
}); });
useEventListener('keyup', e => { useEventListener('keyup', e => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
open.value = false; isOpen.value = false;
} }
}); });
</script> </script>
<template> <template>
<Transition name="fade"> <Transition name="fade">
<template v-if="open"> <template v-if="isOpen">
<div class="fixed inset-0 z-10"> <div class="fixed inset-0">
<div class="absolute bg-black opacity-80 inset-0 z-0" /> <div class="absolute bg-black opacity-80 inset-0 z-0" />
<div class="absolute grid inset-0"> <div class="absolute grid inset-0">
<div class="justify-self-center m-auto" v-on-click-outside="() => open = false"> <div class="justify-self-center m-auto" v-on-click-outside="() => isOpen = false">
<slot /> <slot />
</div> </div>
</div> </div>
@@ -34,13 +47,14 @@ useEventListener('keyup', e => {
</Transition> </Transition>
</template> </template>
<style scoped> <style scoped lang="postcss">
@reference "@/style.css";
.fade-enter-from, .fade-leave-to { .fade-enter-from, .fade-leave-to {
@apply opacity-0; opacity: 0;
} }
.fade-enter-active, .fade-leave-active { .fade-enter-active, .fade-leave-active {
@apply transition-opacity; @apply transition-opacity;
} }
</style> </style>
-19
View File
@@ -1,19 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
value: number;
total: number;
}
const props = defineProps<Props>();
const percentage = computed(() => (props.value / props.total) * 100);
</script>
<template>
<div class="w-full bg-gray-600 rounded-full h-2.5">
<div class="bg-emerald-600 h-2.5 rounded-full" :style="{ width: percentage + '%'}" />
</div>
</template>
-71
View File
@@ -1,71 +0,0 @@
<script setup lang="ts" generic="T">
import {vOnClickOutside} from '@vueuse/components';
import {useVirtualList} from '@vueuse/core';
import {computed, ref} from 'vue';
interface Props {
items: T[];
itemHeight?: number;
}
const props = withDefaults(defineProps<Props>(), {
itemHeight: 24,
});
const modelValue = defineModel<T>();
const isOpen = ref(false);
const currentIndex = ref(-1);
const {list, scrollTo, containerProps, wrapperProps} = useVirtualList(computed(() => props.items), {
itemHeight: () => props.itemHeight,
overscan: 3,
});
const moveDown = () => {
currentIndex.value = currentIndex.value >= props.items.length - 1 ? 0 : currentIndex.value + 1;
scrollTo(currentIndex.value);
};
const moveUp = () => {
currentIndex.value = currentIndex.value <= 0 ? props.items.length - 1 : currentIndex.value - 1;
scrollTo(currentIndex.value);
};
const select = (item?: T) => {
modelValue.value = item;
currentIndex.value = -1;
isOpen.value = false;
};
const submit = () => {
if (currentIndex.value >= 0 && currentIndex.value < props.items.length) {
select(props.items[currentIndex.value]);
} else if (modelValue.value === undefined && props.items.length > 0) {
select(props.items[0]);
}
};
</script>
<template>
<div @click="isOpen = true" v-on-click-outside="() => isOpen = false">
<div class="fake-input" @keyup.enter="submit" @keyup.down="moveDown" @keyup.up="moveUp">
<slot name="input" :value="modelValue" />
</div>
<div v-if="isOpen && items.length" class="z-20 absolute">
<div v-bind="containerProps" class="rounded-b" style="height: 300px">
<div v-bind="wrapperProps">
<div v-for="s in list" :key="s.index"
class="hover:bg-slate-700 cursor-pointer"
:class="s.index === currentIndex ? 'bg-emerald-500' : 'bg-slate-500'"
@click.stop="select(s.data)">
<slot name="item" :item="s.data" />
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@reference "@/style.css";
.fake-input {
@apply flex border bg-slate-500 rounded px-1 py-0.5;
}
</style>
+19 -8
View File
@@ -1,19 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { useVModel } from '@vueuse/core';
const modelValue = defineModel({ default: false }); interface Props {
modelValue?: boolean;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false
});
const emit = defineEmits<Emits>();
const value = useVModel(props, 'modelValue', emit);
</script> </script>
<template> <template>
<label class="flex items-center relative w-max cursor-pointer select-none"> <label class="flex items-center relative w-max cursor-pointer select-none">
<input type="checkbox" class="appearance-none transition-colors cursor-pointer w-10 h-5 rounded-full checked:bg-emerald-500 peer" v-model="modelValue" /> <input type="checkbox" class="appearance-none transition-colors cursor-pointer w-10 h-5 rounded-full checked:bg-emerald-500 peer" v-model="value" />
<span class="w-5 h-5 right-5 absolute rounded-full transform transition-transform bg-slate-100 peer-checked:bg-emerald-200" /> <span class="w-5 h-5 right-5 absolute rounded-full transform transition-transform bg-slate-100 peer-checked:bg-emerald-200" />
</label> </label>
</template> </template>
<style scoped> <style scoped lang="postcss">
@reference "@/style.css"; input:checked ~ span:last-child {
--tw-translate-x: 1.25rem;
input:checked ~ span:last-child { }
transform: translateX(1.25rem);
}
</style> </style>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { vElementHover } from '@vueuse/components';
import { useVModel } from '@vueuse/core';
interface Props {
open: boolean;
}
interface Emit {
(e: 'update:open', value: boolean): void;
}
const props = withDefaults(defineProps<Props>(), {
open: false,
});
const emit = defineEmits<Emit>();
const isOpen = useVModel(props, 'open', emit, {passive: true});
</script>
<template>
<div clas="flex flex-col items-center justify-center" :class="{'open': isOpen}">
<div v-element-hover="(h: boolean) => isOpen = h" class="m-auto header">
<slot name="header" />
</div>
<div v-if="isOpen" class="m-auto">
<div class="z-10 absolute">
<slot />
</div>
</div>
</div>
</template>
+1 -3
View File
@@ -2,8 +2,6 @@ export { default as ClipboardButton } from './ClipboardButton.vue';
export { default as Dropdown } from './Dropdown.vue'; export { default as Dropdown } from './Dropdown.vue';
export { default as LoadingSpinner } from './LoadingSpinner.vue'; export { default as LoadingSpinner } from './LoadingSpinner.vue';
export { default as Modal } from './Modal.vue'; export { default as Modal } from './Modal.vue';
export { default as ProgressBar } from './ProgressBar.vue';
export { default as SelectInput } from './SelectInput.vue';
export { default as SliderCheckbox } from './SliderCheckbox.vue'; export { default as SliderCheckbox } from './SliderCheckbox.vue';
export { default as Tooltip } from './tooltip/Tooltip.vue'; export { default as Tooltip } from './Tooltip.vue';
+6 -9
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import {HeaderComponent, SortDirection} from './sort'; import { SortDirection } from './sort';
interface Props { interface Props {
currentSortKey: string | null; currentSortKey: string | null;
@@ -7,7 +7,6 @@ interface Props {
showColumn?: (k: string) => boolean; showColumn?: (k: string) => boolean;
unsortable?: boolean; unsortable?: boolean;
sortKey: string; sortKey: string;
headerComponent?: HeaderComponent;
} }
interface Emit { interface Emit {
(e: 'sort', key: string, direction: SortDirection): void; (e: 'sort', key: string, direction: SortDirection): void;
@@ -15,26 +14,24 @@ interface Emit {
withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
showColumn: () => () => true, showColumn: () => () => true,
unsortable: false, unsortable: false
headerComponent: 'th',
}); });
const emit = defineEmits<Emit>(); const emit = defineEmits<Emit>();
</script> </script>
<template> <template>
<component v-if="showColumn(sortKey)" :is="headerComponent" class="sort-header"> <th v-if="showColumn(sortKey)">
<slot /> <slot />
<template v-if="!unsortable"> <template v-if="!unsortable">
<span class="asc" :class="{'opacity-20': (currentSortKey != sortKey || sortDirection != 'asc')}" @click="emit('sort', sortKey, 'asc')"></span> <span class="asc" :class="{'opacity-20': (currentSortKey != sortKey || sortDirection != 'asc')}" @click="emit('sort', sortKey, 'asc')"></span>
<span class="desc" :class="{'opacity-20': (currentSortKey != sortKey || sortDirection != 'desc')}" @click="emit('sort', sortKey, 'desc')"></span> <span class="desc" :class="{'opacity-20': (currentSortKey != sortKey || sortDirection != 'desc')}" @click="emit('sort', sortKey, 'desc')"></span>
</template> </template>
</component> </th>
</template> </template>
<style scoped> <style scoped lang="postcss">
@reference "@/style.css"; th {
.sort-header {
@apply relative h-8 pe-3; @apply relative h-8 pe-3;
} }
span.asc, span.desc { span.asc, span.desc {
@@ -1,98 +0,0 @@
<script setup lang="ts">
import {useElementBounding, useVirtualList} from '@vueuse/core';
import {computed, ref} from 'vue';
interface Props {
list?: any[];
itemHeight: number;
headerHeight?: number;
footerHeight?: number;
bottom?: string; // FIXME: use css variable
}
const props = withDefaults(defineProps<Props>(), {
list: () => [],
});
const { list: values, containerProps, wrapperProps } = useVirtualList(computed(() => props.list), {
itemHeight: () => props.itemHeight,
overscan: 3
})
const tableTop = ref<HTMLSpanElement | null>(null);
const { bottom: offset } = useElementBounding(tableTop);
const ypx = computed(() => {
let y = (offset.value ?? 0) + 'px';
if (props.bottom) {
y = `calc(${y} + ${props.bottom})`;
}
return y;
})
const computedHeaderHeight = computed(() => {
const h = props.headerHeight ?? props.itemHeight ?? 0;
return h + 'px';
})
const computedFooterHeight = computed(() => {
const h = props.footerHeight ?? 0;
return h + 'px';
})
const computedWrapperProps = computed(() => ({
...wrapperProps.value,
style: {
...wrapperProps.value.style,
height: `calc(${wrapperProps.value.style.height} + ${computedHeaderHeight.value} + ${computedFooterHeight.value} + 1px)`
}
}))
const itemHeightStyle = computed(() => {
const h = props.itemHeight ?? 0;
return h + 'px';
})
</script>
<template>
<span ref="tableTop" class="h-0" />
<div v-if="list.length > 0" v-bind="containerProps" class="table-container">
<div v-bind="computedWrapperProps">
<table>
<slot :list="values" />
</table>
</div>
</div>
<slot v-else name="empty" />
</template>
<style scoped>
@reference "@/style.css";
div.table-container {
@apply bg-slate-600;
max-height: calc(100vh - v-bind(ypx));
&::-webkit-scrollbar-track {
margin-top: v-bind(computedHeaderHeight);
margin-bottom: v-bind(computedFooterHeight);
}
}
div.table-container:deep(>div) {
@apply bg-slate-800;
>table {
>thead {
@apply sticky z-10;
top: -1px;
}
>tfoot {
@apply bg-slate-600 sticky z-10;
bottom: -1px;
}
>*>tr, >*>tr>td {
height: v-bind(itemHeightStyle);
}
}
}
</style>
+1 -4
View File
@@ -1,6 +1,3 @@
export { default as SortableHeader } from './SortableHeader.vue';
export * from './sort'; export * from './sort';
export { default as SortableHeader } from './SortableHeader.vue';
export { default as VirtualScrollTable } from './VirtualScrollTable.vue';
-44
View File
@@ -1,44 +0,0 @@
import { describe, expect, test } from 'vitest'
import { ref } from 'vue'
import { useSort } from './sort'
describe('useSort', () => {
const array = ref([{ key1: 'b', key2: 'a' }, { key1: 'a', key2: 'b' }])
test('Returns expected properties with default options', () => {
const { sortedArray, headerProps, showColumn } = useSort(array)
expect(sortedArray).toBeDefined()
expect(headerProps).toBeDefined()
expect(showColumn).toBeDefined()
})
test('Returns expected properties with custom options', () => {
const { sortedArray, headerProps, showColumn } = useSort(array, { defaultSortKey: 'key1', defaultSortDirection: 'asc' })
expect(sortedArray.value[0].key1).toBe('a')
expect(headerProps.value.currentSortKey).toBe('key1')
expect(headerProps.value.sortDirection).toBe('asc')
expect(showColumn('key1')).toBe(true)
})
test('Sorts array in ascending order', () => {
const { sortedArray, headerProps } = useSort(array, { defaultSortKey: 'key1', defaultSortDirection: 'asc' })
headerProps.value.onSort('key1', 'asc')
expect(sortedArray.value[0].key1).toBe('a')
})
test('Sorts array in descending order', () => {
const { sortedArray, headerProps } = useSort(array, { defaultSortKey: 'key1', defaultSortDirection: 'desc' })
headerProps.value.onSort('key1', 'desc')
expect(sortedArray.value[0].key1).toBe('b')
})
test('Hides ignored columns', () => {
const { showColumn } = useSort(array, { ignoredColums: ['key1'] })
expect(showColumn('key1')).toBe(false)
})
})
+4 -8
View File
@@ -1,12 +1,10 @@
import { Component, DefineComponent, MaybeRefOrGetter, computed, ref, toValue } from "vue"; import { MaybeRefOrGetter, computed, ref, toValue } from "vue";
export type HeaderComponent = Component | DefineComponent | string;
export type SortDirection = "asc" | "desc"; export type SortDirection = "asc" | "desc";
export type UseSortOptions = { export type UseSortOptions = {
defaultSortKey?: string; defaultSortKey?: string;
defaultSortDirection?: SortDirection; defaultSortDirection?: SortDirection;
ignoredColums?: MaybeRefOrGetter<string[]>; ignoredColums?: MaybeRefOrGetter<string[]>;
headerComponent?: HeaderComponent;
}; };
export const useSort = <T>(array: MaybeRefOrGetter<T[]>, options?: UseSortOptions) => { export const useSort = <T>(array: MaybeRefOrGetter<T[]>, options?: UseSortOptions) => {
@@ -18,14 +16,12 @@ export const useSort = <T>(array: MaybeRefOrGetter<T[]>, options?: UseSortOption
}; };
const showColumn = (sortKey: string) => !toValue(options?.ignoredColums)?.includes(sortKey); const showColumn = (sortKey: string) => !toValue(options?.ignoredColums)?.includes(sortKey);
const headerProps = computed(() => ({ const headerProps = computed(() => ({
onSort: sortBy, onSort: sortBy, showColumn,
showColumn,
currentSortKey: sortKey.value, currentSortKey: sortKey.value,
sortDirection: sortDirection.value, sortDirection: sortDirection.value
headerComponent: options?.headerComponent,
})); }));
const sortedArray = computed(() => toValue(array).toSorted((a, b) => { const sortedArray = computed(() => toValue(array).sort((a, b) => {
if (sortKey.value === null || sortDirection.value === null) { if (sortKey.value === null || sortDirection.value === null) {
return 0; return 0;
} }
-46
View File
@@ -1,46 +0,0 @@
<script setup lang="ts">
import {vElementHover} from '@vueuse/components';
import {useElementBounding} from '@vueuse/core';
import {computed, ref} from 'vue';
import {useSharedWindowSize} from './tooltip';
const open = defineModel('open', { default: false });
const { width, height } = useSharedWindowSize();
const mainDiv = ref<HTMLDivElement | null>(null);
const { top, left } = useElementBounding(mainDiv);
const positions = computed(() => {
if (top.value < height.value / 2) {
if (left.value < width.value / 2) {
return ['top', 'left'];
}
return ['top', 'right'];
}
if (left.value < width.value / 2) {
return ['bottom', 'left'];
}
return ['bottom', 'right'];
})
</script>
<template>
<div ref="mainDiv" class="flex flex-col items-center justify-center" :class="{
'open': open,
'tooltip-top': positions.includes('top'),
'tooltip-bottom': positions.includes('bottom'),
'tooltip-left': positions.includes('left'),
'tooltip-right': positions.includes('right')
}">
<div v-element-hover="(h: boolean) => open = h" class="m-auto header">
<slot name="header" />
</div>
<div v-if="open" class="m-auto">
<div class="z-10 relative">
<div class="absolute">
<slot />
</div>
</div>
</div>
</div>
</template>
-3
View File
@@ -1,3 +0,0 @@
import { createSharedComposable, useWindowSize } from "@vueuse/core";
export const useSharedWindowSize = createSharedComposable(useWindowSize);
-42
View File
@@ -1,42 +0,0 @@
<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>
-3
View File
@@ -1,3 +0,0 @@
export { default as ConfirmModal } from './ConfirmModal.vue';
export { confirm, useConfirmStore } from './useConfirm';
export type { ConfirmOptions } from './useConfirm';
-37
View File
@@ -1,37 +0,0 @@
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);
-23
View File
@@ -1,23 +0,0 @@
import { describe, expect, test } from 'vitest'
import { formatEveDate, formatIsk } from './formaters'
describe('formatIsk', () => {
test('Formats ISK correctly', () => {
expect(formatIsk(123456789)).toBe('123.456.789,00 ISK')
})
})
describe('formatEveDate', () => {
test('Formats EVE date correctly', () => {
const date = new Date(Date.UTC(2022, 0, 1, 0, 0))
expect(formatEveDate(date)).toBe('2022.01.01 00:00')
})
test('Returns empty string for undefined date', () => {
expect(formatEveDate()).toBe('')
})
test('Returns empty string for null date', () => {
expect(formatEveDate(null)).toBe('')
})
})
-8
View File
@@ -10,11 +10,3 @@ export const percentFormater = new Intl.NumberFormat("en-US", {
minimumFractionDigits: 0, minimumFractionDigits: 0,
maximumFractionDigits: 0 maximumFractionDigits: 0
}); });
const timeFormat = new Intl.NumberFormat("en-US", {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
minimumIntegerDigits: 2
});
export const formatEveDate = (date?: Date | null) => !date ? '' : `${date.getUTCFullYear()}.${timeFormat.format(date.getUTCMonth() + 1)}.${timeFormat.format(date.getUTCDate())} ${timeFormat.format(date.getUTCHours())}:${timeFormat.format(date.getUTCMinutes())}`;
File diff suppressed because it is too large Load Diff
-62
View File
@@ -1,62 +0,0 @@
/* 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 = {
}
-127
View File
@@ -1,127 +0,0 @@
/* 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);
};
}
-121
View File
@@ -1,121 +0,0 @@
/* 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);
}
}
-18
View File
@@ -1,18 +0,0 @@
/* 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";
-144
View File
@@ -1,144 +0,0 @@
<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>
-31
View File
@@ -1,31 +0,0 @@
<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>
-33
View File
@@ -1,33 +0,0 @@
<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>
-5
View File
@@ -1,5 +0,0 @@
export * from './ledger';
export {default as LedgerLabel} from './LedgerLabel.vue';
export {default as LedgerSelect} from './LedgerSelect.vue';
export {default as EditLedgerModal} from './EditLedgerModal.vue';
-88
View File
@@ -1,88 +0,0 @@
import {
BalanceResponse,
CombinedLedgerResponse,
CreateCombinedLedgerRequest,
CreateMainLedgerRequest,
LedgerResponse,
MainLedgerResponse,
TransactionResponse,
UpdateCombinedLedgerRequest,
UpdateMainLedgerRequest
} from "@/generated/mammon";
import {defineStore} from "pinia";
import {computed, ref, triggerRef} from "vue";
import {ledgerApi, transactionApi} from "@/mammon";
import {useRouteParams} from "@vueuse/router";
export const LedgerTypes = {
Main: 'MAIN',
Combined: 'COMBINED',
};
export type LedgerType = LedgerResponse['type'];
export type MainLedger = MainLedgerResponse
export type CombinedLedger = CombinedLedgerResponse
export type Ledger = MainLedger | CombinedLedger;
export const systemLedgerRef = 'system';
export const systemLedger = {
type: LedgerTypes.Main,
ledgerId: "00000000-0000-0000-0000-000000000001",
name: "Eve Economy",
balance: 0,
_system: true
} as MainLedger;
export const isMain = (ledger: Ledger): ledger is MainLedger => {
return ledger.type === LedgerTypes.Main;
}
export const isCombined = (ledger: Ledger): ledger is CombinedLedger => {
return ledger.type === LedgerTypes.Combined;
}
export const useLedgersStore = defineStore('ledgers', () => {
const ledgers = ref<Ledger[]>([]);
const addLedger = (ledger: Ledger) => {
ledgers.value.push(ledger);
triggerRef(ledgers);
return ledger;
};
const replaceLedger = (ledger: Ledger) => {
const index = ledgers.value.findIndex(l => l.ledgerId === ledger.ledgerId);
if (index !== -1) {
ledgers.value[index] = ledger;
}
triggerRef(ledgers);
return ledger;
};
const findById = (ledgerId: string): Ledger | undefined => ledgers.value.find(l => l.ledgerId === ledgerId);
const findAllById = (ledgerIds: string[]): Ledger[] => ledgerIds.map(findById).filter((x): x is Ledger => x !== undefined)
const createMain = (ledger: CreateMainLedgerRequest) => ledgerApi.createMainLedger(ledger).then(response => addLedger(response.data as Ledger));
const createCombined = (ledger: CreateCombinedLedgerRequest) => ledgerApi.createCombinedLedger(ledger).then(response => addLedger(response.data as Ledger));
const updateMain = (ledgerId: string, ledger: UpdateMainLedgerRequest) => ledgerApi.updateMainLedger(ledgerId, ledger).then(response => replaceLedger(response.data as Ledger));
const updateCombined = (ledgerId: string, ledger: UpdateCombinedLedgerRequest) => ledgerApi.updateCombinedLedger(ledgerId, ledger).then(response => replaceLedger(response.data as Ledger));
const refresh = () => ledgerApi.findAllLedgers().then(response => ledgers.value = response.data as Ledger[]);
refresh();
return {ledgers, findById, findAllById, createMain, createCombined, updateMain, updateCombined, refresh};
})
const getLedgerId = (ledger: Ledger | string): string => typeof ledger == 'string' ? ledger : ledger.ledgerId;
export const findAllTransactionInLeger = (ledger: Ledger | string): Promise<TransactionResponse[]> => transactionApi.finAllTransactionsInLedger(getLedgerId(ledger)).then(response => response.data)
export const getLedgerBalance = (ledger: Ledger | string): Promise<BalanceResponse> => ledgerApi.findBalanceByLedgerId(getLedgerId(ledger)).then(response => response.data)
export const useLedgerParam = () => {
const ledgersStore = useLedgersStore();
const ledgerId = useRouteParams<string, string>('ledgerId', '', { transform: v => typeof v === 'string' ? v : v[0]});
const ledger = computed(() => ledgersStore.findById(ledgerId.value))
return {ledgerId, ledger};
}
+1 -1
View File
@@ -2,7 +2,7 @@ import log from "loglevel";
import { apply, reg } from "loglevel-plugin-prefix"; import { apply, reg } from "loglevel-plugin-prefix";
export function initLogger() { export function initLogger() {
log.setLevel(import.meta.env.VITE_LOG_LEVEL); log.setLevel(process.env.NODE_ENV === 'production' ? 'info' : 'trace');
reg(log); reg(log);
apply(log, {template: '[%t] %l:'}); apply(log, {template: '[%t] %l:'});
} }
+13
View File
@@ -1,3 +1,4 @@
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';
@@ -16,6 +17,18 @@ const router = createRouter({
}); });
app.use(pinia); app.use(pinia);
const authStore = useAuthStore();
router.beforeEach(async to => {
if (to.name === 'callback') {
await authStore.login();
return { name: 'home' };
} else if (!authStore.isLoggedIn) {
await authStore.redirect();
}
});
app.use(router); app.use(router);
app.mount('#app'); app.mount('#app');
-1
View File
@@ -1 +0,0 @@
export * from './mammonService'
-35
View File
@@ -1,35 +0,0 @@
import {logResource} from "@/service";
import axios from "axios";
import {
AcquisitionApi,
ActivityApi,
CharacterApi,
CharacterRuleBookApi,
LedgerApi,
MarketApi,
ProcessingApi,
RuleBookApi,
TransactionApi
} from "@/generated/mammon";
export const mammonUrl = import.meta.env.VITE_MAMMON_URL;
export const mammonAddCharacterUrl = mammonUrl + "oauth2/authorization/esi"
const mammonAxiosInstance = axios.create({
baseURL: mammonUrl,
headers: {
'Accept': 'application/json',
"Content-Type": "application/json",
},
})
logResource(mammonAxiosInstance)
export const ledgerApi = new LedgerApi(undefined, mammonUrl, mammonAxiosInstance);
export const transactionApi = new TransactionApi(undefined, mammonUrl, mammonAxiosInstance);
export const characterApi = new CharacterApi(undefined, mammonUrl, mammonAxiosInstance);
export const ruleBookApi = new RuleBookApi(undefined, mammonUrl, mammonAxiosInstance);
export const characterRuleBookApi = new CharacterRuleBookApi(undefined, mammonUrl, mammonAxiosInstance);
export const activityApi = new ActivityApi(undefined, mammonUrl, mammonAxiosInstance);
export const processingApi = new ProcessingApi(undefined, mammonUrl, mammonAxiosInstance);
export const acquisitionApi = new AcquisitionApi(undefined, mammonUrl, mammonAxiosInstance);
export const marketApi = new MarketApi(undefined, mammonUrl, mammonAxiosInstance);
-25
View File
@@ -1,25 +0,0 @@
<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>
+13
View File
@@ -0,0 +1,13 @@
import { esiAxiosInstance } from "@/service";
export type MarketOrderHistory = {
average: number;
date: string;
highest: number;
lowest: number;
order_count: number;
volume: number;
}
export const getHistory = async (regionId: number, tyeId: number): Promise<MarketOrderHistory[]> => (await esiAxiosInstance.get(`/markets/${regionId}/history/`, { params: { type_id: tyeId } })).data;
-35
View File
@@ -1,35 +0,0 @@
import { describe, expect, test } from 'vitest'
import { RegionalMarketCache } from './RegionalMarketCache'
describe('RegionalMarketCache', () => {
test('should cache and retrieve values', async () => {
const cache = new RegionalMarketCache<string>(1000)
cache.set(1, 1, 'test')
expect(cache.get(1, 1)).toBe('test')
})
test('should remove values', async () => {
const cache = new RegionalMarketCache<string>(1000)
cache.set(1, 1, 'test')
cache.remove(1, 1)
expect(cache.get(1, 1)).toBeUndefined()
})
test('should compute values if absent', async () => {
const cache = new RegionalMarketCache<string>(1000)
const value = await cache.computeIfAbsent(1, 1, () => Promise.resolve('test'))
expect(value).toBe('test')
expect(cache.get(1, 1)).toBe('test')
})
test('should expire values', async () => {
const cache = new RegionalMarketCache<string>(1)
cache.set(1, 1, 'test')
await new Promise(resolve => setTimeout(resolve, 10))
expect(cache.get(1, 1)).toBeUndefined()
})
})
-50
View File
@@ -1,50 +0,0 @@
class CacheEntry<T> {
public value: T;
public expiration: Date;
constructor(value: T, expiration: Date) {
this.value = value;
this.expiration = expiration;
}
}
export type ExpirationSupplier<T> = (v: T) => Date;
export class RegionalMarketCache<T> {
private cache: Record<number, Record<number, CacheEntry<T>>>;
private expirationSupplier: (v: T) => Date;
constructor(expiration: ExpirationSupplier<T> | number) {
this.cache = {};
this.expirationSupplier = expiration instanceof Function ? expiration : () => new Date(Date.now() + expiration);
}
public get(regionId: number, typeId: number): T | undefined {
const entry = this.cache[regionId]?.[typeId];
if (entry && entry.expiration > new Date()) {
return entry.value;
}
this.remove(regionId, typeId);
return undefined;
}
public set(regionId: number, typeId: number, value: T): void {
this.cache[regionId] = this.cache[regionId] ?? {};
this.cache[regionId][typeId] = new CacheEntry(value, this.expirationSupplier(value));
}
public remove(regionId: number, typeId: number): void {
delete this.cache[regionId]?.[typeId];
}
public async computeIfAbsent(regionId: number, typeId: number, supplier: () => (Promise<T> | T)): Promise<T> {
let value = this.get(regionId, typeId);
if (!value) {
value = await supplier();
this.set(regionId, typeId, value);
}
return value;
}
};
+8
View File
@@ -0,0 +1,8 @@
import { MarketType } from "..";
import { AcquiredMarketItem } from "./acquisition";
export type AcquiredItem = Omit<AcquiredMarketItem, 'type'> & {
type: MarketType,
buy: number,
sell: number
}
-10
View File
@@ -1,10 +0,0 @@
import { MarketType } from "..";
import { RawAcquiredType } from "./acquisition";
export type AcquiredType = Omit<RawAcquiredType, 'type'> & {
type: MarketType,
buy: number,
sell: number
}
export const acquiredTypesToSorted = <T extends {date: Date} = AcquiredType>(array: T[], reverse?: boolean) => array.toSorted((a, b) => reverse ? b.date.getTime() - a.date.getTime() : a.date.getTime() - b.date.getTime())
@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import {LoadingSpinner, Tooltip} from '@/components'; import { LoadingSpinner, Tooltip } from '@/components';
import {formatIsk} from '@/formaters'; import { formatIsk } from '@/formaters';
import {getHistory, getHistoryQuartils} from '@/market'; import { getHistory, jitaId } from '@/market';
import {ArrowTrendingDownIcon, ArrowTrendingUpIcon} from '@heroicons/vue/24/outline'; import { getHistoryQuartils } from '@/market/scan';
import {computedAsync} from '@vueuse/core'; import { ArrowTrendingDownIcon, ArrowTrendingUpIcon } from '@heroicons/vue/24/outline';
import {ref, watchEffect} from 'vue'; import { computedAsync } from '@vueuse/core';
import { ref, watchEffect } from 'vue';
const trendingScale = 3; const trendingScale = 3;
@@ -22,7 +23,7 @@ const q1 = ref(0);
const median = ref(0); const median = ref(0);
const q3 = ref(0); const q3 = ref(0);
const lineColor = ref(''); const lineColor = ref('');
const history = computedAsync(() => getHistory(props.id), []); const history = computedAsync(() => getHistory(jitaId, props.id), []);
watchEffect(async () => { watchEffect(async () => {
if (!open.value || !props.id) { if (!open.value || !props.id) {
@@ -52,7 +53,7 @@ watchEffect(async () => {
<ArrowTrendingDownIcon v-else /> <ArrowTrendingDownIcon v-else />
</template> </template>
<template #default> <template #default>
<div class="bg-slate-500 -left-1/2 relative tooltip-content" v-if="history.length > 0"> <div class="bg-slate-500 -left-1/2 relative" v-if="history.length > 0">
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -63,9 +64,9 @@ watchEffect(async () => {
</thead> </thead>
<tbody> <tbody>
<tr :class="lineColor"> <tr :class="lineColor">
<td class="text-right text-nowrap">{{ formatIsk(q1) }}</td> <td class="text-right">{{ formatIsk(q1) }}</td>
<td class="text-right text-nowrap">{{ formatIsk(median) }}</td> <td class="text-right">{{ formatIsk(median) }}</td>
<td class="text-right text-nowrap">{{ formatIsk(q3) }}</td> <td class="text-right">{{ formatIsk(q3) }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -74,26 +75,14 @@ watchEffect(async () => {
</Tooltip> </Tooltip>
</template> </template>
<style scoped> <style scoped lang="postcss">
@reference "@/style.css";
.tooltip { .tooltip {
@apply ms-auto; @apply ms-auto;
>:deep(div.header) { >:deep(div.header) {
@apply btn-icon px-2; @apply btn-icon px-2;
} }
&.open { &.open>:deep(div.header) {
&.tooltip-top>:deep(div.header) { @apply rounded-t-md bg-slate-600;
@apply rounded-t-md bg-slate-600;
}
&.tooltip-bottom {
.tooltip-content {
bottom: 79px;
}
>:deep(div.header) {
@apply rounded-b-md bg-slate-600;
}
}
} }
} }
</style> </style>
+70 -198
View File
@@ -1,162 +1,75 @@
<script setup lang="ts"> <script setup lang="ts">
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table'; import { SortableHeader, useSort } from '@/components/table';
import {MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore} from "@/market"; import { formatIsk, percentFormater } from '@/formaters';
import {MinusIcon, PlusIcon} from '@heroicons/vue/24/outline'; import { MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore } from "@/market";
import {useStorage} from '@vueuse/core'; import { MinusIcon, PlusIcon } from '@heroicons/vue/24/outline';
import {computed, ref} from 'vue'; import { useStorage } from '@vueuse/core';
import {AcquiredType} from './AcquiredType'; import { computed, ref } from 'vue';
import { AcquiredItem } from './AcquiredItem';
import AcquisitionQuantilsTooltip from './AcquisitionQuantilsTooltip.vue'; import AcquisitionQuantilsTooltip from './AcquisitionQuantilsTooltip.vue';
import {formatEveDate, formatIsk, percentFormater} from "@/formaters.ts";
type Result = { type Result = {
id: string;
type: MarketType; type: MarketType;
typeID: number;
name: string; name: string;
buy: number; buy: number;
sell: number; sell: number;
price: number; price: number;
remaining: number; count: number;
quantity: number;
precentProfit: number; precentProfit: number;
iskProfit: number; iskProfit: number;
date: Date;
acquisitions: AcquiredType[];
} }
interface Props { interface Props {
items?: AcquiredType[]; items?: AcquiredItem[];
infoOnly?: boolean;
showAll?: boolean;
ignoredColums?: string[] | string;
defaultSortKey?: string;
} }
interface Emits { interface Emits {
(e: 'buy', type: AcquiredType[], price: number, buy: number, sell: number): void; (e: 'buy', type: MarketType, price: number, buy: number, sell: number): void;
(e: 'sell', type: AcquiredType[]): void; (e: 'sell', type: MarketType): void;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
items: () => [], items: () => []
infoOnly: false,
showAll: false,
ignoredColums: () => [],
defaultSortKey: 'precentProfit',
}); });
defineEmits<Emits>(); defineEmits<Emits>();
const columnsToIgnore = computed(() => {
const ic = typeof props.ignoredColums === 'string' ? [props.ignoredColums] : props.ignoredColums;
if (props.infoOnly && !ic.includes('buttons')) {
return [...ic, 'buttons'];
}
return ic;
});
const marketTaxStore = useMarketTaxStore(); const marketTaxStore = useMarketTaxStore();
const threshold = useStorage('market-acquisition-threshold', 10); const threshold = useStorage('market-acquisition-threshold', 10);
const filter = ref(""); const filter = ref("");
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => { const { sortedArray, headerProps } = useSort<Result>(computed(() => props.items
const filteredItems = props.items.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase())); .filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase()))
.map(r => {
const precentProfit = marketTaxStore.calculateProfit(r.price, r.sell);
if (props.showAll) { return {
return filteredItems.map(r => { type: r.type,
const precentProfit = marketTaxStore.calculateProfit(r.price, r.sell); typeID: r.type.id,
name: r.type.name,
return { buy: r.buy,
id: r.id, sell: r.sell,
type: r.type, price: r.price,
name: r.type.name, count: r.remaining,
buy: r.buy,
sell: r.sell,
price: r.price,
remaining: r.remaining,
quantity: r.quantity,
precentProfit,
iskProfit: r.price * precentProfit * r.remaining,
date: r.date,
acquisitions: [r]
};
});
}
const list: Result[] = [];
const groups = Map.groupBy(filteredItems, r => r.type.id);
groups.forEach((group, typeID) => {
const first = group[0];
if (!first) {
return;
}
const total = group.reduce((acc, r) => acc + r.quantity, 0);
const totalRemaining = group.reduce((acc, r) => acc + r.remaining, 0);
const price = group.reduce((acc, r) => acc + r.price * r.remaining, 0) / totalRemaining;
const precentProfit = marketTaxStore.calculateProfit(price, first.sell);
list.push({
id: typeID.toString(),
type: first.type,
name: first.type.name,
buy: first.buy,
sell: first.sell,
price: price,
remaining: totalRemaining,
quantity: total,
precentProfit, precentProfit,
iskProfit: price * precentProfit * totalRemaining, iskProfit: r.price * precentProfit * r.remaining
date: first.date, };
acquisitions: group })), {
}); defaultSortKey: 'precentProfit',
}); defaultSortDirection: 'desc'
return list; })
}), {
defaultSortKey: props.defaultSortKey,
defaultSortDirection: 'desc',
ignoredColums: columnsToIgnore
})
const getLineColor = (result: Result) => { const getLineColor = (result: Result) => {
if (result.precentProfit >= (threshold.value / 100)) { if (result.precentProfit >= (threshold.value / 100)) {
return 'line-green'; return 'line-green';
} else if (result.precentProfit < 0) { } else if (result.precentProfit < 0) {
return 'line-red'; return 'line-red';
} }
return ''; return '';
} }
const total = computed(() => {
if (sortedArray.value.length <= 1) {
return null;
}
const first = sortedArray.value[0];
if (!first) {
return null;
}
const sameItem = sortedArray.value.every(r => r.type.id === first.type.id);
const quantity = sameItem ? sortedArray.value.reduce((acc, r) => acc + r.quantity, 0) : 0;
const totalRemaining = sameItem ? sortedArray.value.reduce((acc, r) => acc + r.remaining, 0) : 0;
const price = sortedArray.value.reduce((acc, r) => acc + r.price * r.remaining, 0) / totalRemaining;
const precentProfit = marketTaxStore.calculateProfit(price, first.sell);
const iskProfit = sortedArray.value.reduce((acc, r) => acc + r.iskProfit, 0);
return {
sameItem,
price,
remaining: totalRemaining,
quantity,
precentProfit,
iskProfit
};
});
</script> </script>
<template> <template>
<div class="flex" v-if="!infoOnly"> <div class="flex">
<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"> <div class="end">
@@ -165,89 +78,48 @@ const total = computed(() => {
</div> </div>
<div class="end"> <div class="end">
<span>Filter: </span> <span>Filter: </span>
<input type="search" class="w-96" v-model="filter" /> <input type="search" class="w-96" v-model="filter" >
</div> </div>
</div> </div>
</div> </div>
<VirtualScrollTable :list="sortedArray" :itemHeight="33" :footerHeight="!!total ? 33 : 0" bottom="1rem"> <table>
<template #default="{ list }"> <thead>
<thead> <tr>
<tr> <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="price">Bought Price</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="date">Bought at</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="count">Bought Amount</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="price">Bought Price</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="precentProfit">Profit (%)</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="remaining">Remaining Amount</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="iskProfit">Profit (ISK)</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="precentProfit">Profit (%)</SortableHeader> <th></th>
<SortableHeader v-bind="headerProps" sortKey="iskProfit">Profit (ISK)</SortableHeader> </tr>
<SortableHeader v-bind="headerProps" sortKey="buttons" unsortable /> </thead>
</tr> <tbody>
</thead> <tr v-for="r in sortedArray" :key="r.typeID" :class="getLineColor(r)">
<tbody> <td>
<tr v-for="r in list" :key="r.index" :class="getLineColor(r.data)"> <div class="flex">
<td v-if="showColumn('name')"> <MarketTypeLabel :id="r.typeID" :name="r.name" />
<div class="flex"> <AcquisitionQuantilsTooltip :id="r.typeID" :buy="r.buy" :sell="r.sell" />
<MarketTypeLabel :id="r.data.type.id" :name="r.data.name" /> </div>
<AcquisitionQuantilsTooltip :id="r.data.type.id" :buy="r.data.buy" :sell="r.data.sell" /> </td>
</div> <td class="text-right">{{ formatIsk(r.buy) }}</td>
</td> <td class="text-right">{{ formatIsk(r.sell) }}</td>
<td v-if="showColumn('buy')" class="text-right">{{ formatIsk(r.data.buy) }}</td> <td class="text-right">{{ formatIsk(r.price) }}</td>
<td v-if="showColumn('sell')" class="text-right">{{ formatIsk(r.data.sell) }}</td> <td class="text-right">{{ r.count }}</td>
<td v-if="showColumn('date')" class="text-right">{{ formatEveDate(r.data.date) }}</td> <td class="text-right">{{ percentFormater.format(r.precentProfit) }}</td>
<td v-if="showColumn('price')" class="text-right">{{ formatIsk(r.data.price) }}</td> <td class="text-right">{{ formatIsk(r.iskProfit) }}</td>
<td v-if="showColumn('remaining')" class="text-right">{{ r.data.remaining }}/{{ r.data.quantity }}</td> <td class="text-right">
<td v-if="showColumn('precentProfit')" class="text-right">{{ percentFormater.format(r.data.precentProfit) }}</td> <button class="btn-icon me-1" @click="$emit('buy', r.type, r.price, r.buy, r.sell)"><PlusIcon /></button>
<td v-if="showColumn('iskProfit')" class="text-right">{{ formatIsk(r.data.iskProfit) }}</td> <button class="btn-icon me-1" @click="$emit('sell', r.type)"><MinusIcon /></button>
<td v-if="showColumn('buttons')" class="text-right"> </td>
<button class="btn-icon me-1" @click="$emit('buy', r.data.acquisitions, r.data.price, r.data.buy, r.data.sell)"><PlusIcon /></button> </tr>
<button class="btn-icon me-1" @click="$emit('sell', r.data.acquisitions)"><MinusIcon /></button> </tbody>
</td> </table>
</tr>
</tbody>
<tfoot v-if="!!total">
<tr>
<td v-if="showColumn('name')">Total</td>
<td v-if="showColumn('buy')">
<template v-if="!showColumn('name')">Total</template>
</td>
<td v-if="showColumn('sell')">
<template v-if="!showColumn('name') && !showColumn('buy')">Total</template>
</td>
<td v-if="showColumn('date')">
<template v-if="!showColumn('name') && !showColumn('buy') && !showColumn('sell')">Total</template>
</td>
<td v-if="showColumn('price')" class="text-right">
<template v-if="total.sameItem">
{{ formatIsk(total.price) }}
</template>
</td>
<td v-if="showColumn('remaining')" class="text-right">
<template v-if="total.sameItem">
{{ total.remaining }}/{{ total.quantity }}
</template>
</td>
<td v-if="showColumn('precentProfit')" class="text-right">
<template v-if="total.sameItem">
{{ percentFormater.format(total.precentProfit) }}
</template>
</td>
<td v-if="showColumn('iskProfit')" class="text-right">{{ formatIsk(total.iskProfit) }}</td>
<td v-if="showColumn('buttons')" />
</tr>
</tfoot>
</template>
<template #empty>
<div class="text-center mt-4">
<span>No items found</span>
</div>
</template>
</VirtualScrollTable>
</template> </template>
<style scoped> <style scoped lang="postcss">
@reference "@/style.css";
div.end { div.end {
@apply justify-self-end ms-2; @apply justify-self-end ms-2;
} }
</style> </style>@/components/table
+9 -8
View File
@@ -1,11 +1,12 @@
<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 { ref } from 'vue';
import {useAcquiredTypesStore} from './acquisition'; import { useAcquiredItemStore } from './acquisition';
const acquiredTypesStore = useAcquiredTypesStore();
const acquiredItemStore = useAcquiredItemStore();
const modalOpen = ref<boolean>(false); const modalOpen = ref<boolean>(false);
const type = ref<MarketType>(); const type = ref<MarketType>();
@@ -37,7 +38,7 @@ const add = () => {
return; return;
} }
acquiredTypesStore.addAcquiredType(id, count.value, price.value); acquiredItemStore.addAcquiredItem(id, count.value, price.value);
modalOpen.value = false; modalOpen.value = false;
} }
@@ -65,4 +66,4 @@ defineExpose({ open });
</div> </div>
</div> </div>
</Modal> </Modal>
</template> </template>./acquisitions
+11 -31
View File
@@ -2,44 +2,29 @@
import { Modal } from '@/components'; import { Modal } from '@/components';
import { MarketType, MarketTypeLabel } from '@/market'; import { MarketType, MarketTypeLabel } from '@/market';
import { ref } from 'vue'; import { ref } from 'vue';
import { AcquiredType, acquiredTypesToSorted } from './AcquiredType'; import { useAcquiredItemStore } from './acquisition';
import { useAcquiredTypesStore } from './acquisition';
const acquiredTypesStore = useAcquiredTypesStore(); const acquiredItemStore = useAcquiredItemStore();
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 types = ref<AcquiredType[]>([]);
const open = (t: AcquiredType[]) => { const open = (t: MarketType) => {
if (t.length === 0) { type.value = t;
return;
}
types.value = acquiredTypesToSorted(t);
type.value = t[0].type;
count.value = 1; count.value = 1;
modalOpen.value = true; modalOpen.value = true;
} }
const remove = async () => { const remove = () => {
if (!types.value) { const id = type.value?.id;
if (!id) {
modalOpen.value = false; modalOpen.value = false;
return; return;
} }
let c = count.value; acquiredItemStore.removeAcquiredItem(id, count.value);
for (const type of types.value) {
const remaining = type.remaining;
await acquiredTypesStore.removeAcquiredType(type.id, c);
c -= remaining;
if (c <= 0) {
break;
}
}
modalOpen.value = false; modalOpen.value = false;
} }
@@ -54,15 +39,10 @@ defineExpose({ open });
<div class="flex p-4"> <div class="flex p-4">
<div class="flex me-2 mb-auto"> <div class="flex me-2 mb-auto">
<span>Count: </span> <span>Count: </span>
<div class="ms-2"> <input class="ms-2" type="number" min="0" step="1" v-model="count" @keyup.enter="remove" />
<input type="number" min="0" step="1" v-model="count" @keyup.enter="remove" />
<div>
<button class="px-2 mt-2 bg-slate-600 hover:bg-slate-700 border rounded cursor-pointer" @click="count = types.reduce((acc, t) => acc + t.remaining, 0)">All</button>
</div>
</div>
</div> </div>
<button class="mb-auto" @click="remove">Remove</button> <button class="mb-auto" @click="remove">Remove</button>
</div> </div>
</div> </div>
</Modal> </Modal>
</template> </template>./acquisitions
+54 -29
View File
@@ -1,43 +1,68 @@
import {defineStore} from "pinia"; import { useAuthStore } from "@/auth";
import {computed, ref} from "vue"; import { marbasAxiosInstance } from "@/service";
import {acquisitionApi} from "@/mammon"; import { defineStore } from "pinia";
import {AcquisitionResponse, AcquisitionResponseSourceEnum} from "@/generated/mammon"; import { computed, onMounted, ref } from "vue";
export type AcquiredTypeSource = 'bo' | 'so' | 'prod' | 'misc'; export type AcquiredMarketItem = {
id: number;
export type RawAcquiredType = {
id: string;
type: number; type: number;
quantity: number; quantity: number;
remaining: number; remaining: number;
price: number; price: number;
date: Date; date: Date;
source: AcquisitionResponseSourceEnum; source: 'bo' | 'so' | 'prod';
user: number;
} }
const toAcquiredType = (a: AcquisitionResponse): RawAcquiredType => ({ const endpoint = '/api/acquisitions';
id: a.acquisitionId,
type: a.marketTypeId,
quantity: a.quantity,
remaining: a.remaining,
price: a.unitCost,
date: new Date(a.datetime),
source: a.source,
});
export const useAcquiredTypesStore = defineStore('market-acquisition', () => { export const useAcquiredItemStore = defineStore('market-acquisition', () => {
const acquiredTypes = ref<RawAcquiredType[]>([]); const _acquiredItems = ref<AcquiredMarketItem[]>([]);
const authStore = useAuthStore();
const types = computed(() => acquiredTypes.value.filter(item => item.remaining > 0)); const items = computed(() => _acquiredItems.value);
const addAcquiredItem = async (type: number, quantity: number, price: number) => {
_acquiredItems.value = [..._acquiredItems.value, (await marbasAxiosInstance.post<AcquiredMarketItem>(endpoint, {
type: type,
quantity: quantity,
remaining: quantity,
price: price,
date: new Date(),
source: 'bo',
user: authStore.userId,
})).data];
};
const removeAcquiredItem = async (type: number, quantity: number) => {
const found = _acquiredItems.value.find(item => item.type === type);
// Display-only: the backend exposes no write endpoint yet, so buy/sell are no-ops. if (!found) {
const addAcquiredType = async (_type: number, _quantity: number, _price: number, _source?: AcquiredTypeSource) => {}; return;
const removeAcquiredType = async (_id: string, _quantity: number) => {}; }
const refresh = () => acquisitionApi.findAllAcquisitions() if (found.remaining <= 0) {
.then(response => acquiredTypes.value = response.data.map(toAcquiredType)); _acquiredItems.value = _acquiredItems.value.filter(i => i.type !== type);
refresh(); } else {
_acquiredItems.value = _acquiredItems.value.map(i => {
if (i.type === item.type) {
return item;
} else {
return i;
}
});
}
return { acquiredTypes: types, addAcquiredType, removeAcquiredType, refresh }; const item = {
}); ...found,
remaining: found.remaining - quantity
};
await marbasAxiosInstance.put(`${endpoint}/${item.id}`, item);
};
onMounted(async () => {
_acquiredItems.value = (await marbasAxiosInstance.get<AcquiredMarketItem[]>(endpoint)).data.filter(item => item.remaining > 0);
});
return { items, addAcquiredItem, removeAcquiredItem };
});
+1 -1
View File
@@ -1,4 +1,4 @@
export * from './AcquiredType'; export * from './AcquiredItem';
export * from './acquisition'; export * from './acquisition';
export { default as AcquisitionResultTable } from './AcquisitionResultTable.vue'; export { default as AcquisitionResultTable } from './AcquisitionResultTable.vue';
+27 -25
View File
@@ -1,45 +1,47 @@
import {defineStore} from 'pinia'; import { defineStore } from 'pinia';
import {RegionalMarketCache} from '../RegionalMarketCache'; import { ref } from 'vue';
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 CACHE_DURATION = 1000 * 60 * 5; // 5 minutes type MarketTypePriceCache = {
const BATCH_SIZE = 100; price: MarketTypePrice,
date: Date
}
const cacheDuration = 1000 * 60 * 5; // 5 minutes
const priceGetters = {
evepraisal: getEvepraisalPrices,
fuzzwork: getfuzzworkPrices
}
export const useApraisalStore = defineStore('appraisal', () => { export const useApraisalStore = defineStore('appraisal', () => {
const cache: RegionalMarketCache<MarketTypePrice> = new RegionalMarketCache(CACHE_DURATION); const cache = ref<Record<number, MarketTypePriceCache>>({});
const getPricesUncached = getMammonPrices; const getPricesUncached = priceGetters.fuzzwork;
const getPrice = async (type: MarketType, regionId?: number): Promise<MarketTypePrice> => (await getPrices([type], regionId))[0]; const getPrice = async (type: MarketType): Promise<MarketTypePrice> => (await getPrices([type]))[0];
const getPrices = async (types: MarketType[], regionId?: number): Promise<MarketTypePrice[]> => { const getPrices = async (types: MarketType[]): Promise<MarketTypePrice[]> => {
const now = new Date();
const cached: MarketTypePrice[] = []; const cached: MarketTypePrice[] = [];
const uncached: MarketType[] = []; const uncached: MarketType[] = [];
const rId = regionId ?? jitaId;
types.forEach(t => { types.forEach(t => {
const cachedPrice = cache.get(rId, t.id); const cachedPrice = cache.value[t.id];
if (cachedPrice) { if (cachedPrice && now.getTime() - cachedPrice.date.getTime() < cacheDuration) {
cached.push(cachedPrice); cached.push(cachedPrice.price);
} else { } else {
uncached.push(t); uncached.push(t);
} }
}); });
if (uncached.length > 0) { if (uncached.length > 0) {
const batches: Promise<MarketTypePrice[]>[] = []; const prices = await getPricesUncached(uncached);
for (let i = 0; i < uncached.length; i += BATCH_SIZE) { prices.forEach(p => cache.value[p.type.id] = { price: p, date: now });
batches.push(getPricesUncached(uncached.slice(i, i + BATCH_SIZE))); return [...cached, ...prices];
}
const prices = (await Promise.all(batches)).flat();
prices.forEach(p => cache.set(rId, p.type.id, p));
return [ ...cached, ...prices ];
} }
return cached; return cached;
}; };
+21 -1
View File
@@ -1,5 +1,7 @@
import {logResource} from '@/service'; import { logResource } from '@/service';
import axios from 'axios'; import axios from 'axios';
import { MarketType } from "../type";
import { PriceGetter } from './MarketTypePrice';
export const evepraisalAxiosInstance = axios.create({ export const evepraisalAxiosInstance = axios.create({
baseURL: import.meta.env.VITE_EVEPRAISAL_URL, baseURL: import.meta.env.VITE_EVEPRAISAL_URL,
@@ -9,3 +11,21 @@ export const evepraisalAxiosInstance = axios.create({
}, },
}) })
logResource(evepraisalAxiosInstance) logResource(evepraisalAxiosInstance)
const batchSize = 100;
export const getEvepraisalPrices: PriceGetter = async types => {
const batches = [];
for (let i = 0; i < types.length; i += batchSize) {
batches.push(evepraisalAxiosInstance.post(`/appraisal.json?market=jita&persist=no&raw_textarea=${types.slice(i, i + batchSize).map(t => t.name).join("%0A")}`));
}
return (await Promise.all(batches))
.flatMap(b => b.data.appraisal.items)
.map((item: any) => ({
type: types.find(t => t.name === item.typeName) as MarketType,
buy: item.prices.buy.max,
sell: item.prices.sell.min,
orderCount: item.prices.all.order_count
}));
};
+39
View File
@@ -0,0 +1,39 @@
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;
-20
View File
@@ -1,20 +0,0 @@
import {marketApi} from '@/mammon/mammonService';
import {MarketTypePrice, PriceGetter} from './MarketTypePrice';
export const getMammonPrices: PriceGetter = async types => {
if (types.length === 0) {
return [];
}
const typesById = new Map(types.map(t => [t.id, t]));
const response = await marketApi.currentPrices(types.map(t => t.id));
return response.data.reduce<MarketTypePrice[]>((prices, p) => {
const type = typesById.get(p.marketTypeId);
if (type) {
prices.push({ type, buy: p.buy, sell: p.sell, orderCount: p.orderCount });
}
return prices;
}, []);
};
-7
View File
@@ -1,7 +0,0 @@
import { MarketHistoryResponse } from "@/generated/mammon";
import { marketApi } from "@/mammon";
export type MarketHistory = MarketHistoryResponse;
export const getHistory = async (typeId: number): Promise<MarketHistory[]> =>
(await marketApi.findHistory(typeId)).data;
-2
View File
@@ -1,2 +0,0 @@
export * from './MarketHistory';
export * from './HistoryQuartils';
+1 -4
View File
@@ -1,10 +1,7 @@
export * from './RegionalMarketCache';
export * from './history';
export * from './tax'; export * from './tax';
export * from './type'; export * from './type';
export * from './MarketOrderHistory';
export * from './appraisal'; export * from './appraisal';
export * from './market'; export * from './market';
export { default as IskLabel } from './IskLabel.vue';
+2
View File
@@ -1 +1,3 @@
export const jitaId = 10000002; export const jitaId = 10000002;
@@ -1,4 +1,4 @@
import { MarketHistory } from "@/market"; import { MarketOrderHistory } from "@/market";
export type HistoryQuartils = { export type HistoryQuartils = {
totalVolume: number, totalVolume: number,
@@ -7,7 +7,7 @@ export type HistoryQuartils = {
q3: number, q3: number,
} }
export const getHistoryQuartils = (history: MarketHistory[], days?: number): HistoryQuartils => { export const getHistoryQuartils = (history: MarketOrderHistory[], days?: number): HistoryQuartils => {
const now = Date.now(); const now = Date.now();
const volumes = history const volumes = history
@@ -51,7 +51,7 @@ export const getHistoryQuartils = (history: MarketHistory[], days?: number): His
}; };
} }
const estimateVolume = (history: MarketHistory): number => { const estimateVolume = (history: MarketOrderHistory): number => {
if (history.volume === 0) { if (history.volume === 0) {
return 0; return 0;
} }
+64 -78
View File
@@ -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 } from '@/components/table';
import {formatIsk, percentFormater} from "@/formaters"; import { formatIsk, percentFormater } from '@/formaters';
import {MarketType, MarketTypeLabel} from "@/market"; import { MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore } from "@/market";
import {ShoppingCartIcon} from '@heroicons/vue/24/outline'; 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 { ScanResult, getHistoryQuartils } from '.';
import {ScanResult} from './scan';
type Result = { type Result = {
type: MarketType; type: MarketType;
@@ -18,8 +17,6 @@ type Result = {
q1: number; q1: number;
median: number; median: number;
q3: number; q3: number;
totalVolume: number;
acquisitions: number;
profit: number; profit: number;
score: number; score: number;
} }
@@ -27,19 +24,17 @@ type Result = {
interface Props { interface Props {
items?: ScanResult[]; items?: ScanResult[];
infoOnly?: boolean; infoOnly?: boolean;
ignoredColums?: string[] | string; ignoredColums?: string[];
} }
interface Emits { interface Emits {
(e: 'buy', type: MarketType, buy: number, sell: number): void; (e: 'buy', type: MarketType, buy: number, sell: number): void;
(e: 'remove', type: MarketType): void;
} }
const scoreFormater = new Intl.NumberFormat("en-US", { const scoreFormater = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 0 maximumFractionDigits: 0
}); });
const volumeFormater = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 0
});
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
items: () => [], items: () => [],
@@ -48,25 +43,24 @@ const props = withDefaults(defineProps<Props>(), {
}); });
defineEmits<Emits>(); defineEmits<Emits>();
const acquiredTypesStore = useAcquiredTypesStore(); const marketTaxStore = useMarketTaxStore();
const days = useStorage('market-scan-days', 365);
const threshold = useStorage('market-scan-threshold', 10); const threshold = useStorage('market-scan-threshold', 10);
const filter = ref(""); const filter = ref("");
const onlyCheap = ref(false); const onlyCheap = ref(false);
const columnsToIgnore = computed(() => { const columnsToIgnore = computed(() => {
const ic = typeof props.ignoredColums === 'string' ? [props.ignoredColums] : props.ignoredColums; if (props.infoOnly && !props.ignoredColums.includes('buttons')) {
return [...props.ignoredColums, 'buttons'];
if (props.infoOnly && !ic.includes('buttons')) {
return [...ic, 'buttons'];
} }
return ic; return props.ignoredColums;
}); });
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => props.items const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => props.items
.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase())) .filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase()))
.map(r => { .map(r => {
const acquisitions = columnsToIgnore.value.includes('acquisitions') ? 0 : acquiredTypesStore.acquiredTypes const quartils = getHistoryQuartils(r.history, days.value);
.filter(t => t.type === r.type.id) const profit = quartils.q1 === 0 || quartils.q3 === 0 ? 0 : marketTaxStore.calculateProfit(quartils.q1, quartils.q3);
.reduce((a, b) => a + b.remaining, 0); 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);
return { return {
type: r.type, type: r.type,
@@ -74,13 +68,11 @@ 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: r.q1, q1: quartils.q1,
median: r.median, median: quartils.median,
q3: r.q3, q3: quartils.q3,
totalVolume: r.totalVolume, profit,
acquisitions, score
profit: r.profit,
score: r.score
}; };
}).filter(r => !onlyCheap.value || (r.buy <= r.q1 && r.profit >= (threshold.value / 100)))), { }).filter(r => !onlyCheap.value || (r.buy <= r.q1 && r.profit >= (threshold.value / 100)))), {
defaultSortKey: 'score', defaultSortKey: 'score',
@@ -104,67 +96,61 @@ const getLineColor = (result: Result) => {
<template> <template>
<div v-if="!infoOnly" class="flex mb-2 mt-4"> <div v-if="!infoOnly" class="flex mb-2 mt-4">
<div class="flex justify-self-end ms-auto"> <div class="flex justify-self-end ms-auto">
<TaxInput />
<div class="end"> <div class="end">
<span>Profit Threshold: </span> <span>Profit Threshold: </span>
<input type="number" min="0" max="1000" step="1" v-model="threshold" /> <input type="number" min="0" max="1000" step="1" v-model="threshold" />
</div> </div>
<div class="end">
<span>Days: </span>
<input type="number" min="1" max="365" step="1" v-model="days" />
</div>
<div class="end flex"> <div class="end flex">
<SliderCheckbox class="me-1" v-model="onlyCheap" /> Show only cheap items <SliderCheckbox class="me-1" v-model="onlyCheap" /> Show only cheap items
</div> </div>
<div class="end"> <div class="end">
<span>Filter: </span> <span>Filter: </span>
<input type="search" class="w-96" v-model="filter" /> <input type="search" class="w-96" v-model="filter" >
</div> </div>
</div> </div>
</div> </div>
<VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem"> <table>
<template #default="{ list }"> <thead>
<thead> <tr>
<tr> <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="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="profit">Profit</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="totalVolume">Volume</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="score">Score</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="profit">Profit</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="buttons" unsortable></SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="score">Score</SortableHeader> </tr>
<SortableHeader v-bind="headerProps" sortKey="acquisitions">Acquisitions</SortableHeader> </thead>
<SortableHeader v-bind="headerProps" sortKey="buttons" unsortable /> <tbody>
</tr> <tr v-for="r in sortedArray" :key="r.typeID" :class="getLineColor(r)">
</thead> <td v-if="showColumn('name')">
<tbody> <MarketTypeLabel :id="r.typeID" :name="r.name" />
<tr v-for="r in list" :key="r.data.typeID" :class="getLineColor(r.data)"> </td>
<td v-if="showColumn('name')"> <td v-if="showColumn('buy')" class="text-right">{{ formatIsk(r.buy) }}</td>
<MarketTypeLabel :id="r.data.typeID" :name="r.data.name" /> <td v-if="showColumn('sell')" class="text-right">{{ formatIsk(r.sell) }}</td>
</td> <td v-if="showColumn('q1')" class="text-right">{{ formatIsk(r.q1) }}</td>
<td v-if="showColumn('buy')" class="text-right">{{ formatIsk(r.data.buy) }}</td> <td v-if="showColumn('median')" class="text-right">{{ formatIsk(r.median) }}</td>
<td v-if="showColumn('sell')" class="text-right">{{ formatIsk(r.data.sell) }}</td> <td v-if="showColumn('q3')" class="text-right">{{ formatIsk(r.q3) }}</td>
<td v-if="showColumn('q1')" class="text-right">{{ formatIsk(r.data.q1) }}</td> <td v-if="showColumn('profit')" class="text-right">{{ percentFormater.format(r.profit) }}</td>
<td v-if="showColumn('median')" class="text-right">{{ formatIsk(r.data.median) }}</td> <td v-if="showColumn('score')" class="text-right">{{ scoreFormater.format(r.score) }}</td>
<td v-if="showColumn('q3')" class="text-right">{{ formatIsk(r.data.q3) }}</td> <td v-if="showColumn('buttons')" class="text-right">
<td v-if="showColumn('totalVolume')" class="text-right">{{ volumeFormater.format(r.data.totalVolume) }}</td> <button class="btn-icon me-1" @click="$emit('buy', r.type, r.buy, r.sell)"><ShoppingCartIcon /></button>
<td v-if="showColumn('profit')" class="text-right">{{ percentFormater.format(r.data.profit) }}</td> <button class="btn-icon me-1" @click="$emit('remove', r.type)"><BookmarkSlashIcon /></button>
<td v-if="showColumn('score')" class="text-right">{{ scoreFormater.format(r.data.score) }}</td> </td>
<td v-if="showColumn('acquisitions')" class="text-right">{{ r.data.acquisitions }}</td> </tr>
<td v-if="showColumn('buttons')" class="text-right"> </tbody>
<button class="btn-icon me-1" title="Add acquisitions" @click="$emit('buy', r.data.type, r.data.buy, r.data.sell)"><ShoppingCartIcon /></button> </table>
</td>
</tr>
</tbody>
</template>
<template #empty>
<div class="text-center mt-4">
<span>No items found</span>
</div>
</template>
</VirtualScrollTable>
</template> </template>
<style scoped> <style scoped lang="postcss">
@reference "@/style.css";
div.end { div.end {
@apply justify-self-end ms-2; @apply justify-self-end ms-2;
} }
</style> </style>@/components/table
+2
View File
@@ -1,3 +1,5 @@
export * from './HistoryQuartils';
export * from './scan'; export * from './scan';
export { default as ScanResultTable } from './ScanResultTable.vue'; export { default as ScanResultTable } from './ScanResultTable.vue';
+34 -20
View File
@@ -1,26 +1,40 @@
import { MarketType } from "@/market"; import { MarketOrderHistory, MarketType, MarketTypePrice, getHistory, jitaId } from "@/market";
import { MarketScanResponse } from "@/generated/mammon"; import { defineStore } from "pinia";
import { computed, ref } from "vue";
export type ScanResult = { export type ScanResult = {
type: MarketType; type: MarketType;
buy: number; history: MarketOrderHistory[];
sell: number; buy: number,
q1: number; sell: number,
median: number; orderCount: number,
q3: number;
totalVolume: number;
profit: number;
score: number;
} }
export const toScanResult = (res: MarketScanResponse, type: MarketType): ScanResult => ({ interface MarketScan {
type, owner: string;
buy: res.buy, types: number[];
sell: res.sell, };
q1: res.q1,
median: res.median, const marketScans = 'marketScans';
q3: res.q3,
totalVolume: res.totalVolume, export const useMarketScanStore = defineStore(marketScans, () => {
profit: res.profit, const marketScan = ref<MarketScan>();
score: res.score,
const types = computed(() => marketScan.value?.types ?? []);
const setTypes = async (_types: number[]) => {
}
const addType = async (type: number) => {
if (!types.value.includes(type)) {
await setTypes([...types.value, type]);
}
}
const removeType = async (type: number) => {
if (types.value.includes(type)) {
await setTypes(types.value.filter(t => t !== type));
}
}
return { types, setTypes, addType, removeType };
}); });
export const createResult = async (id: number, price: MarketTypePrice): Promise<ScanResult> => ({ history: await getHistory(jitaId, id), ...price });
+6 -6
View File
@@ -1,23 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import {useMarketTaxStore} from "./tax"; import { storeToRefs } from "pinia";
import { useMarketTaxStore } from "./tax";
const marketTaxStore = useMarketTaxStore(); const { brokerFee, scc } = storeToRefs(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="marketTaxStore.brokerFee" /> <input type="number" min="1" max="3" step="0.01" v-model="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="marketTaxStore.scc" > <input type="number" min="3.6" max="8" step="0.01" v-model="scc" >
</div> </div>
</template> </template>
<style scoped> <style scoped lang="postcss">
@reference "@/style.css";
div.end { div.end {
@apply justify-self-end ms-2; @apply justify-self-end ms-2;
} }
+46 -29
View File
@@ -1,40 +1,57 @@
import {marketApi} from '@/mammon/mammonService'; import { marbasAxiosInstance } from "@/service";
import type {MarketTypeResponse} from '@/generated/mammon';
export type MarketType = MarketTypeResponse; export type MarketType = {
id: number;
const cache = new Map<number, MarketType>(); // TODO move to pinia store group_id: number;
marketgroup_id: number;
const BATCH_SIZE = 100; name: string;
published: boolean;
const fetchTypes = async (ids: number[]): Promise<void> => { description: string;
const missing = ids.filter(id => !cache.has(id)); basePrice: number;
if (missing.length === 0) { icon_id: number;
return; volume: number;
} 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];
} }
const ids = types.filter((t): t is number => typeof t === 'number'); return (await marbasAxiosInstance.post<MarketType[]>("/api/types/search", types.map(t => {
await fetchTypes(ids); if (typeof t === "number") {
return ids.map(id => cache.get(id)).filter((t): t is MarketType => t !== undefined); return { id: t };
} 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[]> => {
if (search.length === 0) { return (await marbasAxiosInstance.post<MarketType[]>("/api/types/search", [{
return []; name__icontains: search,
} marketgroup_id___not: null,
const types = await marketApi.searchTypes(search).then(r => r.data); marketgroup_id__in___not: blueprintMarketGrous,
types.forEach(t => cache.set(t.id, t)); }])).data;
return types;
} }
+19 -13
View File
@@ -1,18 +1,25 @@
<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, useVModel } from '@vueuse/core';
import log from 'loglevel'; import log from 'loglevel';
import {nextTick, ref, watch, watchEffect} from 'vue'; import { nextTick, ref, watch, watchEffect } from 'vue';
import {MarketType, searchMarketTypes} from './MarketType'; import { MarketType, searchMarketTypes } from './MarketType';
import MarketTypeLabel from "./MarketTypeLabel.vue"; import MarketTypeLabel from "./MarketTypeLabel.vue";
interface Props {
modelValue?: MarketType;
}
interface Emits { interface Emits {
(e: 'update:modelValue', value?: MarketType): void;
(e: 'submit'): void; (e: 'submit'): void;
} }
const modelValue = defineModel<MarketType>(); const props = defineProps<Props>();
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>();
const value = useVModel(props, 'modelValue', emit);
const isOpen = ref(false); const isOpen = ref(false);
const name = ref(''); const name = ref('');
const suggestions = ref<MarketType[]>([]); const suggestions = ref<MarketType[]>([]);
@@ -40,7 +47,7 @@ const moveUp = () => {
} }
const select = (type?: MarketType) => { const select = (type?: MarketType) => {
log.debug('Select:', type); log.debug('Select:', type);
modelValue.value = type; value.value = type;
currentIndex.value = -1; currentIndex.value = -1;
suggestions.value = []; suggestions.value = [];
isOpen.value = false; isOpen.value = false;
@@ -55,18 +62,18 @@ const submit = async () => {
select(v); select(v);
await nextTick(); await nextTick();
} else if (modelValue.value === undefined && suggestions.value.length > 0) { } else if (props.modelValue === undefined && suggestions.value.length > 0) {
select(suggestions.value[0]); select(suggestions.value[0]);
await nextTick(); await nextTick();
} }
if (modelValue.value === undefined) { if (value.value === undefined) {
return; return;
} }
emit('submit'); emit('submit');
} }
watch(() => modelValue.value, async v => { watch(() => props.modelValue, async v => {
if (v === undefined) { if (v === undefined) {
name.value = ''; name.value = '';
} else { } else {
@@ -89,10 +96,10 @@ watchEffect(async () => {
<template> <template>
<div @click="() => isOpen = true" v-on-click-outside="() => isOpen = false"> <div @click="() => isOpen = true" v-on-click-outside="() => isOpen = false">
<div class="fake-input"> <div class="fake-input">
<img v-if="modelValue?.id" :src="`https://images.evetech.net/types/${modelValue.id}/icon?size=32`" alt="" /> <img v-if="value?.id" :src="`https://images.evetech.net/types/${value.id}/icon`" alt="" />
<input type="text" v-model="name" @keyup.enter="submit" @keyup.down="moveDown" @keyup.up="moveUp" /> <input type="text" v-model="name" @keyup.enter="submit" @keyup.down="moveDown" @keyup.up="moveUp" />
</div> </div>
<div v-if="suggestions.length > 1" class="z-20 absolute w-96"> <div v-if="suggestions.length > 1" class="z-10 absolute w-96">
<div v-bind="containerProps" class="rounded-b" style="height: 300px"> <div v-bind="containerProps" class="rounded-b" style="height: 300px">
<div v-bind="wrapperProps"> <div v-bind="wrapperProps">
<div v-for="s in list" :key="s.index" class="hover:bg-slate-700" :class="{'bg-slate-500': s.index !== currentIndex, 'bg-emerald-500': s.index === currentIndex}" @click="select(s.data)"> <div v-for="s in list" :key="s.index" class="hover:bg-slate-700" :class="{'bg-slate-500': s.index !== currentIndex, 'bg-emerald-500': s.index === currentIndex}" @click="select(s.data)">
@@ -104,8 +111,7 @@ watchEffect(async () => {
</div> </div>
</template> </template>
<style scoped> <style scoped lang="postcss">
@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;
+10 -28
View File
@@ -1,9 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import {ClipboardButton} from '@/components'; import { ClipboardButton } from '@/components';
import {InformationCircleIcon} from '@heroicons/vue/24/outline';
import {routeNames} from '@/routes';
import {computedAsync} from "@vueuse/core";
import {getMarketType} from "@/market";
interface Props { interface Props {
@@ -12,39 +8,25 @@ interface Props {
hideCopy?: boolean; hideCopy?: boolean;
} }
const props = withDefaults(defineProps<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 || computedName" class="flex flex-row"> <div v-if="id || name">
<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`" class="inline-block w-5 h-5 me-1" alt="" />
<template v-if="computedName"> <template v-if="name">
{{ computedName }} {{ name }}
<RouterLink v-if="id" :to="{ name: routeNames.marketTypes, params: { type: id } }" class="btn-icon ms-1 me-1 mt-1" title="Show item info"> <ClipboardButton v-if="!hideCopy" :value="name" />
<InformationCircleIcon />
</RouterLink>
<ClipboardButton v-if="!hideCopy" :value="computedName" />
</template> </template>
</div> </div>
</template> </template>
<style scoped> <style scoped lang="postcss">
@reference "@/style.css"; button:deep(>svg) {
@apply relative top-0.5 !w-4 !h-4;
button:deep(>svg), .btn-icon:deep(>svg) {
@apply !w-4 !h-4;
} }
</style> </style>
-24
View File
@@ -1,24 +0,0 @@
<script setup lang="ts">
import {mammonAddCharacterUrl} from "@/mammon";
import {CharacterLabel, useCharactersStore} from "@/characters";
import {ArrowPathIcon} from '@heroicons/vue/24/outline';
const charactersStore = useCharactersStore()
const addCharacter = () => {
window.location.replace(mammonAddCharacterUrl);
}
</script>
<template>
<div class="grid mb-2 mt-4">
<div class="mb-4 border-b-1 flex justify-end">
<button class="mb-2" @click="addCharacter">Add chacarcter</button>
</div>
<div v-for="character in charactersStore.characters" :key="character.characterId" class="flex items-center mb-2">
<CharacterLabel class="grow" :character="character" />
<button class="btn-icon" @click="charactersStore.reloadActivities(character.characterId)"><ArrowPathIcon /></button>
</div>
</div>
</template>
-27
View File
@@ -1,27 +0,0 @@
<script setup lang="ts">
import {RouterView} from 'vue-router';
import {EditLedgerModal, useLedgersStore} from "@/ledger";
import {ref} from "vue";
import {activityApi, processingApi} from "@/mammon";
const ledgersStore = useLedgersStore();
const editLedgerModal = ref<typeof EditLedgerModal>();
const processActivities = async () => {
await activityApi.fetchAllNewActivities();
await processingApi.processNewActivities();
await ledgersStore.refresh();
}
</script>
<template>
<div class="mt-4">
<div class="mb-4 border-b-1 flex justify-end">
<button class="mb-2 ms-2" @click="processActivities">Process Activities</button>
<button class="mb-2 ms-2" @click="editLedgerModal?.open()">New Ledger</button>
</div>
<EditLedgerModal ref="editLedgerModal" />
<RouterView />
</div>
</template>
+13 -4
View File
@@ -1,12 +1,12 @@
<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: routeNames.marketTypes}" class="tab"> <RouterLink :to="{name: 'market-types'}" class="tab">
<span>Item Info</span> <span>Item Info</span>
</RouterLink> </RouterLink>
<RouterLink to="/market/scan" class="tab"> <RouterLink to="/market/scan" class="tab">
@@ -18,4 +18,13 @@ import {routeNames} from '@/routes';
</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>
-18
View File
@@ -1,18 +0,0 @@
<script setup lang="ts">
import {RouterLink, RouterView} from "vue-router";
import {routeNames} from '@/routes';
</script>
<template>
<div class="mt-4">
<div class="flex border-b-2 border-emerald-500">
<RouterLink :to="{ name: routeNames.listRuleBooks }" class="tab">
<span>Rule Books</span>
</RouterLink>
<RouterLink to="/characters/rules" class="tab">
<span>Characters Rules</span>
</RouterLink>
</div>
<RouterView />
</div>
</template>
-106
View File
@@ -1,106 +0,0 @@
<script setup lang="ts">
import {findAllTransactionInLeger, useLedgerParam} from "@/ledger";
import {computedAsync} from "@vueuse/core";
import {TransactionResponse} from "@/generated/mammon";
import {IskLabel} from "@/market";
import {SortableHeader, useSort, VirtualScrollTable} from "@/components/table";
import {TransferList, TransferTypes} from "@/transaction";
import {Dropdown} from "@/components";
import {CharacterLabel, useCharactersStore} from "@/characters";
import {formatEveDate} from "@/formaters.ts";
const {ledgerId} = useLedgerParam();
const charactersStore = useCharactersStore();
const transactions = computedAsync<TransactionResponse[]>(async () => {
if (ledgerId.value) {
return await findAllTransactionInLeger(ledgerId.value);
}
return [];
}, []);
const { sortedArray, headerProps } = useSort(computedAsync(() => Promise.all(transactions.value.map(async transaction => {
const character = await charactersStore.findById(transaction.characterId);
return {
character,
characterName: character?.name ?? "",
transactionId: transaction.transactionId,
description: transaction.description,
date: new Date(transaction.datetime),
balance: getIskBalance(transaction),
transfers: transaction.transfers
}
})), []), { defaultSortKey: 'date', defaultSortDirection: 'desc' });
const getIskBalance = (transaction: TransactionResponse) => {
if (!ledgerId.value) {
return 0;
}
let balance = 0;
for (const transfer of transaction.transfers) {
if (transfer.type === TransferTypes.Isk) {
if (transfer.toLedgerId === ledgerId.value) {
balance += transfer.amount;
} else if (transfer.fromLedgerId === ledgerId.value) {
balance -= transfer.amount;
}
}
}
return balance;
}
</script>
<template>
<div class="mt-4">
<VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem">
<template #default="{ list }">
<thead>
<tr>
<SortableHeader v-bind="headerProps" sortKey="characterName">Character</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="date">Date</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="balance">Isk Change</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="description">Description</SortableHeader>
</tr>
</thead>
<tbody>
<tr v-for="t in list" :key="t.data.transactionId">
<td>
<CharacterLabel v-if="t.data.character" :character="t.data.character" />
</td>
<td>
<Dropdown class="transfer-dropdown">
<template #button>
{{formatEveDate(t.data.date)}}
</template>
<TransferList :transfers="t.data.transfers" />
</Dropdown>
</td>
<td class="text-right">
<IskLabel :amount="t.data.balance" />
</td>
<td>{{t.data.description}}</td>
</tr>
</tbody>
</template>
</VirtualScrollTable>
</div>
</template>
<style scoped>
@reference "@/style.css";
tr:hover>td>.transfer-dropdown :deep(>button) {
@apply bg-slate-900;
}
.transfer-dropdown :deep(>button) {
@apply bg-slate-800 hover:bg-slate-900 border-none flex items-center w-full;
}
.transfer-dropdown.dropdown-open :deep(>button) {
@apply bg-slate-800 rounded-b-none;
}
</style>
-57
View File
@@ -1,57 +0,0 @@
<script setup lang="ts">
import {EditLedgerModal, Ledger, LedgerLabel, useLedgersStore} from "@/ledger";
import {nextTick, ref} from "vue";
import {PencilSquareIcon} from "@heroicons/vue/24/outline";
import {IskLabel} from "@/market";
import {SortableHeader, useSort, VirtualScrollTable} from "@/components/table";
const ledgersStore = useLedgersStore();
const { sortedArray, headerProps } = useSort<Ledger>(() => ledgersStore.ledgers);
const editModal = ref<typeof EditLedgerModal>();
const editingLedgerId = ref("");
const openEdit = async (ledgerId: string) => {
editingLedgerId.value = ledgerId;
await nextTick();
editModal.value?.open();
};
</script>
<template>
<div class="mt-4">
<VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem">
<template #default="{ list }">
<thead>
<tr>
<SortableHeader v-bind="headerProps" sortKey="name">Ledger</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="balance">Isk Balance</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="buttons" unsortable />
</tr>
</thead>
<tbody>
<tr v-for="l in list" :key="l.data.ledgerId">
<td>
<LedgerLabel :ledger="l.data" :link="true" />
</td>
<td class="text-right">
<IskLabel class="ms-2" :amount="l.data.balance" />
</td>
<td class="text-right">
<button class="btn-icon ms-2" @click="openEdit(l.data.ledgerId)"><PencilSquareIcon /></button>
</td>
</tr>
</tbody>
</template>
<template #empty>
<div class="text-center mt-4">
<span>No ledgers found</span>
</div>
</template>
</VirtualScrollTable>
</div>
<EditLedgerModal ref="editModal" :ledger-id="editingLedgerId" />
</template>
-22
View File
@@ -1,22 +0,0 @@
<script setup lang="ts">
import {RouterLink, RouterView} from 'vue-router';
import {isMain, useLedgerParam} from "@/ledger";
import {routeNames} from "@/routes.ts";
const {ledgerId, ledger} = useLedgerParam();
</script>
<template>
<div v-if="ledger" class="mt-4">
<div class="flex border-b-2 border-emerald-500">
<RouterLink :to="{name: routeNames.viewLedgerBalance}" class="tab">
<span>Balance</span>
</RouterLink>
<RouterLink v-if="isMain(ledger)" :to="{name: routeNames.listLedgerTransactions}" class="tab">
<span>Transactions</span>
</RouterLink>
</div>
<RouterView />
</div>
</template>
-60
View File
@@ -1,60 +0,0 @@
<script setup lang="ts">
import {getLedgerBalance, useLedgerParam} from "@/ledger";
import {computedAsync} from "@vueuse/core";
import {BalanceResponse} from "@/generated/mammon";
import {getMarketType, IskLabel, MarketTypeLabel} from "@/market";
import {SortableHeader, useSort, VirtualScrollTable} from "@/components/table";
const {ledgerId} = useLedgerParam();
const balance = computedAsync<BalanceResponse>(async () => {
if (ledgerId.value) {
return await getLedgerBalance(ledgerId.value);
}
return undefined;
});
const { sortedArray, headerProps } = useSort(computedAsync(async () => {
const itemBalances = balance.value?.itemBalances;
if (!itemBalances) {
return [];
}
return await Promise.all(itemBalances.map(async i => {
const item = await getMarketType(i.typeId);
return {
...i,
name: item.name
};
}));
}, []));
</script>
<template>
<div v-if="balance" class="mt-4">
<div class="flex justify-end w-full">
<IskLabel class="mb-2" :amount="balance.iskBalance" />
</div>
<VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem">
<template #default="{ list }">
<thead>
<tr>
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="quantity">Balance</SortableHeader>
</tr>
</thead>
<tbody>
<tr v-for="i in list" :key="i.data.typeId">
<td>
<MarketTypeLabel :id="i.data.typeId" :name="i.data.name" />
</td>
<td class="text-right">{{i.data.quantity}}</td>
</tr>
</tbody>
</template>
</VirtualScrollTable>
</div>
</template>
+10 -19
View File
@@ -1,28 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import {getMarketTypes, MarketTypePrice, useApraisalStore} from "@/market"; import { MarketTypePrice, getMarketTypes, useApraisalStore } from "@/market";
import {AcquiredType, AcquisitionResultTable, BuyModal, SellModal, useAcquiredTypesStore} from '@/market/acquisition'; import { AcquiredItem, AcquisitionResultTable, BuyModal, SellModal, useAcquiredItemStore } from '@/market/acquisition';
import {ref, watch} from 'vue'; import { ref, watch } from 'vue';
import {activityApi, processingApi} from "@/mammon";
const buyModal = ref<typeof BuyModal>(); const buyModal = ref<typeof BuyModal>();
const sellModal = ref<typeof SellModal>(); const sellModal = ref<typeof SellModal>();
const apraisalStore = useApraisalStore(); const apraisalStore = useApraisalStore();
const acquiredTypesStore = useAcquiredTypesStore(); const acquiredItemStore = useAcquiredItemStore();
const items = ref<AcquiredType[]>([]); const items = ref<AcquiredItem[]>([]);
const refresh = async () => { watch(() => acquiredItemStore.items, async itms => {
await activityApi.fetchAllNewActivities();
await processingApi.processNewActivities();
await acquiredTypesStore.refresh();
}
watch(() => acquiredTypesStore.acquiredTypes, async itms => {
if (itms.length === 0) { if (itms.length === 0) {
return; return;
} }
const prices = await apraisalStore.getPrices(await getMarketTypes([...new Set(itms.map(i => i.type))])); const prices = await apraisalStore.getPrices(await getMarketTypes(itms.map(i => i.type)));
items.value = itms.map(i => { items.value = itms.map(i => {
const price = prices.find(p => p.type.id === i.type) as MarketTypePrice; const price = prices.find(p => p.type.id === i.type) as MarketTypePrice;
@@ -40,13 +34,10 @@ watch(() => acquiredTypesStore.acquiredTypes, async itms => {
<template> <template>
<div class="mt-4"> <div class="mt-4">
<div class="flex">
<button class="ms-auto" @click="refresh">Refresh</button>
</div>
<template v-if="items.length > 0"> <template v-if="items.length > 0">
<AcquisitionResultTable :items="items" @buy="(types, price, buy, sell) => buyModal?.open(types[0].type, { 'Price': price, 'Buy': buy, 'Sell': sell })" @sell="types => sellModal?.open(types)" ignoredColums="date" /> <AcquisitionResultTable :items="items" @buy="(type, price, buy, sell) => buyModal?.open(type, { 'Price': price, 'Buy': buy, 'Sell': sell })" @sell="type => sellModal?.open(type)" />
<BuyModal ref="buyModal" /> <BuyModal ref="buyModal" />
<SellModal ref="sellModal" /> <SellModal ref="sellModal" />
</template> </template>
</div> </div>
</template> </template>@/market/acquisition
+63 -48
View File
@@ -1,64 +1,79 @@
<script setup lang="ts"> <script setup lang="ts">
import {getMarketTypes, TaxInput, useMarketTaxStore} from "@/market"; import { MarketType, MarketTypeInput, MarketTypePrice, getHistory, getMarketTypes, jitaId, useApraisalStore } from "@/market";
import {BuyModal} from '@/market/acquisition'; import { BuyModal } from '@/market/acquisition';
import {ScanResult, ScanResultTable, toScanResult} from '@/market/scan'; import { ScanResult, ScanResultTable, createResult, useMarketScanStore } from '@/market/scan';
import {marketApi} from "@/mammon"; import { ref, watch } from 'vue';
import {useStorage} from "@vueuse/core";
import {ref, watch} from 'vue';
const buyModal = ref<typeof BuyModal>(); const buyModal = ref<typeof BuyModal>();
const marketTaxStore = useMarketTaxStore(); const item = ref<MarketType>();
const days = useStorage('market-scan-days', 365);
const apraisalStore = useApraisalStore();
const markeyScanStore = useMarketScanStore();
const items = ref<ScanResult[]>([]); const items = ref<ScanResult[]>([]);
const loading = ref(false); const addOrRelaod = async (type: MarketType) => {
const typeID = type.id;
const [history, price] = await Promise.all([
getHistory(jitaId, typeID),
apraisalStore.getPrice(type)
]);
const itm = {
type,
history,
buy: price.buy,
sell: price.sell,
orderCount: price.orderCount
};
const scan = async () => { if (items.value.some(i => i.type.id === typeID)) {
loading.value = true; items.value = items.value.map(i => i.type.id === typeID ? itm : i);
try { } else {
const { data } = await marketApi.scanMarket( items.value = [ ...items.value, itm];
days.value,
marketTaxStore.brokerFee / 100,
marketTaxStore.scc / 100
);
const types = await getMarketTypes(data.map(r => r.marketTypeId));
items.value = data.flatMap(r => {
const type = types.find(t => t.id === r.marketTypeId);
return type ? [toScanResult(r, type)] : [];
});
} finally {
loading.value = false;
} }
} }
const addItem = async () => {
if (!item.value) {
// TODO error
return;
}
watch([days, () => marketTaxStore.brokerFee, () => marketTaxStore.scc], scan, { immediate: true }); addOrRelaod(item.value);
item.value = undefined;
}
const removeItem = (type: MarketType) => {
items.value = items.value.filter(i => i.type.id !== type.id);
}
watch(items, async itms => markeyScanStore.setTypes(itms.map(i => i.type.id)));
watch(() => markeyScanStore.types, async t => {
const typesToLoad = t.filter(t => !items.value.some(i => i.type.id === t));
if (typesToLoad.length === 0) {
return;
}
const prices = await apraisalStore.getPrices(await getMarketTypes(typesToLoad));
items.value = [
...items.value,
...(await Promise.all(typesToLoad.map(i => createResult(i, prices.find(p => p.type.id === i) as MarketTypePrice))))
];
}, { immediate: true });
</script> </script>
<template> <template>
<div class="flex mb-2 mt-4"> <div class="grid mb-2 mt-4">
<div class="flex justify-self-end ms-auto"> <div class="w-auto flex">
<TaxInput /> <span>Item: </span>
<div class="end"> <MarketTypeInput class="ms-2" v-model="item" @submit="addItem"/>
<span>Days: </span> <button class="justify-self-end ms-2" @click="addItem">Add</button>
<input type="number" min="1" max="365" step="1" v-model="days" />
</div>
</div> </div>
</div> </div>
<hr /> <template v-if="items.length > 0">
<div v-if="loading" class="text-center mt-4"> <hr />
<span>Scanning market</span> <ScanResultTable :items="items" @buy="(type, buy, sell) => buyModal?.open(type, { 'Buy': buy, 'Sell': sell })" @remove="removeItem" />
</div>
<template v-else>
<ScanResultTable :items="items" @buy="(type, buy, sell) => buyModal?.open(type, { 'Buy': buy, 'Sell': sell })" />
<BuyModal ref="buyModal" /> <BuyModal ref="buyModal" />
</template> </template>
</template> </template>
<style scoped>
@reference "@/style.css";
div.end {
@apply justify-self-end ms-2;
}
</style>
+29 -51
View File
@@ -1,15 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import {ClipboardButton} from '@/components'; import { ClipboardButton } from '@/components';
import {getMarketType, MarketType, MarketTypeInput, useApraisalStore, useMarketTaxStore} from "@/market"; import { MarketType, MarketTypeInput, getMarketType, useApraisalStore } from "@/market";
import {AcquisitionResultTable, BuyModal, useAcquiredTypesStore} from '@/market/acquisition'; import { BuyModal } from '@/market/acquisition';
import {ScanResultTable, toScanResult} from '@/market/scan'; import { ScanResultTable, createResult, useMarketScanStore } from '@/market/scan';
import {marketApi} from "@/mammon"; import { BookmarkIcon, BookmarkSlashIcon, ShoppingCartIcon } from '@heroicons/vue/24/outline';
import {ShoppingCartIcon} from '@heroicons/vue/24/outline'; import { computedAsync } from '@vueuse/core/index.cjs';
import log from "loglevel"; import log from "loglevel";
import {computed, ref, watch} from "vue"; import { computed, ref, watch } from "vue";
import {useRoute, useRouter} from "vue-router"; import { useRoute, useRouter } from "vue-router";
import {routeNames} from "@/routes";
import {computedAsync, useStorage} from "@vueuse/core";
const buyModal = ref<typeof BuyModal>(); const buyModal = ref<typeof BuyModal>();
@@ -19,44 +17,29 @@ const item = ref<MarketType>();
const inputItem = ref<MarketType>(); const inputItem = ref<MarketType>();
const apraisalStore = useApraisalStore(); const apraisalStore = useApraisalStore();
const marketTaxStore = useMarketTaxStore();
const days = useStorage('market-scan-days', 365);
const price = computedAsync(() => item.value ? apraisalStore.getPrice(item.value) : undefined); const price = computedAsync(() => item.value ? apraisalStore.getPrice(item.value) : undefined);
const result = computedAsync(async () => { const markeyScanStore = useMarketScanStore();
const result = computedAsync(async () => item.value && price.value ? await createResult(item.value?.id, price.value) : undefined);
const isTracked = computed(() => item.value ? markeyScanStore.types.includes(item.value.id) : false);
const toogleTracking = () => {
if (!item.value) { if (!item.value) {
return undefined; return;
} }
if (isTracked.value) {
markeyScanStore.removeType(item.value.id);
} else {
markeyScanStore.addType(item.value.id);
}
}
const { data } = await marketApi.scanMarketType(
item.value.id,
days.value,
marketTaxStore.brokerFee / 100,
marketTaxStore.scc / 100
);
return toScanResult(data, item.value);
});
const acquiredTypesStore = useAcquiredTypesStore();
const acquisitions = computed(() => {
const p = price.value;
return !p ?[] : acquiredTypesStore.acquiredTypes
.filter(t => t.type === item.value?.id)
.map(i => ({
...i,
type: p.type,
buy: p.buy,
sell: p.sell
}));
});
const view = () => { const view = () => {
if (!inputItem.value) { if (!inputItem.value) {
return; return;
} }
router.push({ router.push({
name: routeNames.marketTypes, name: 'market-types',
params: { params: {
type: inputItem.value.id type: inputItem.value.id
} }
@@ -89,33 +72,28 @@ watch(useRoute(), async route => {
<template v-if="item"> <template v-if="item">
<hr> <hr>
<div class="p-2 mb-4 flex"> <div class="p-2 mb-4 flex">
<img v-if="item.id" :src="`https://images.evetech.net/types/${item.id}/icon?size=64`" class="type-image inline-block me-2" alt="" /> <img v-if="item.id" :src="`https://images.evetech.net/types/${item.id}/icon?size=64`" class="type-image inline-block me-2" />
<div class="inline-block align-top w-full"> <div class="inline-block align-top w-full">
<div class="flex"> <div class="flex">
<span class="text-lg font-semibold">{{ item.name }}</span> <span class="text-lg font-semibold">{{ item.name }}</span>
<div class="ms-auto"> <div class="ms-auto">
<ClipboardButton class="ms-1" :value="item.name" /> <ClipboardButton class="ms-1" :value="item.name" />
<button v-if="price" class="btn-icon ms-1" title="Add acquisitions" @click="buyModal?.open(item, { 'Buy': price.buy, 'Sell': price.sell })"><ShoppingCartIcon /></button> <button v-if="price" class="btn-icon ms-1" @click="buyModal?.open(item, { 'Buy': price.buy, 'Sell': price.sell })"><ShoppingCartIcon /></button>
<button class="btn-icon ms-1" :title="isTracked ? 'Untrack' : 'Track'" @click="toogleTracking">
<BookmarkSlashIcon v-if="isTracked" />
<BookmarkIcon v-else />
</button>
</div> </div>
</div> </div>
<p v-if="item.description" class="text-sm">{{ item.description }}</p> <p v-if="item.description" class="text-sm">{{ item.description }}</p>
</div> </div>
</div> </div>
<div v-if="result" class="mb-4"> <ScanResultTable v-if="result" :items="[result]" infoOnly />
<span>Market Info:</span>
<ScanResultTable :items="[result]" infoOnly :ignoredColums="['name', 'acquisitions']" />
</div>
<div v-if="acquisitions && acquisitions.length > 0">
<span>Acquisitions:</span>
<AcquisitionResultTable :items="acquisitions" infoOnly showAll :ignoredColums="['name', 'buy', 'sell']" defaultSortKey="date"/>
</div>
</template> </template>
<BuyModal ref="buyModal" /> <BuyModal ref="buyModal" />
</template> </template>
<style scoped> <style scoped lang="postcss">
@reference "@/style.css";
img.type-image { img.type-image {
width: 64px; width: 64px;
height: 64px; height: 64px;
-93
View File
@@ -1,93 +0,0 @@
<script setup lang="ts">
import {Character, CharacterLabel, useCharactersStore} from "@/characters";
import {useRoute} from "vue-router";
import {computed, ref, watch, watchEffect} from "vue";
import log from "loglevel";
import {
RuleBook,
useCharacterRuleBooksStore,
useRuleBooksStore
} from "@/rules";
import {isMain, Ledger, LedgerSelect, systemLedger, useLedgersStore} from "@/ledger";
type Bindings = { [key: string]: Ledger; };
const ruleBookStore = useRuleBooksStore();
const characterRuleBooksStore = useCharacterRuleBooksStore();
const charactersStore = useCharactersStore();
const ledgersStore = useLedgersStore();
const ledgersToUse = computed(() => [systemLedger, ...ledgersStore.ledgers.filter(isMain)]);
const character = ref<Character>();
const ruleBook = ref<RuleBook>();
const bindings = ref<Bindings>({});
const ledgerRefs = computed<string[]>(() => ruleBook.value?.ledgerRefs ?? [])
watchEffect(async () => {
const characterId = character.value?.characterId;
if (characterId) {
const characterRuleBook = characterRuleBooksStore.findByCharacterId(characterId);
ruleBook.value = ruleBookStore.findById(characterRuleBook?.ruleBook.ruleBookId ?? '');
bindings.value = Object.fromEntries(
Object.entries(characterRuleBook?.bindings ?? {})
.map(([key, id]) => [key, ledgersToUse.value.find(l => l.ledgerId === id) ?? systemLedger])
);
}
});
const save = () => {
const characterId = character.value?.characterId;
const ruleBookId = ruleBook.value?.ruleBookId;
if (characterId && ruleBookId) {
characterRuleBooksStore.setForCharacter(characterId, {
ruleBookId,
bindings: Object.fromEntries(
Object.entries(bindings.value)
.map(([key, ledger]) => [key, ledger.ledgerId])
)
})
}
}
watch(useRoute(), async route => {
if (route.params.characterId) {
const id = parseInt(typeof route.params.characterId === 'string' ? route.params.characterId : route.params.characterId[0]);
character.value = await charactersStore.findById(id);
log.info('Loaded character:', character.value);
} else {
character.value = undefined;
log.info('No character to load');
}
}, { immediate: true })
</script>
<template>
<div v-if="character" class="grid mb-2 mt-4">
<div class="mb-2 border-b-1 flex">
<CharacterLabel class="flex grow mb-2" :character="character" :size="64" />
<div>
<button @click="save">Save</button>
</div>
</div>
<div class="flex-col border-b-1">
Rule Book:
<select class="me-2 mb-2 w-50" v-model="ruleBook">
<option v-for="rb in ruleBookStore.ruleBooks" :key="rb.ruleBookId" :value="rb">{{ rb.name }}</option>
</select>
</div>
<div class="flex-col border-b-1">
Ledger Bindings:
<div class="flex flex-wrap items-center mb-2 mt-2">
<div class="me-2" v-for="ref in ledgerRefs" :ref="ref">
<span class="me-1">{{ref}}:</span>
<LedgerSelect :ledgers="ledgersToUse" :modelValue="bindings[ref] ?? systemLedger" @update:modelValue="value => { if (value) bindings[ref] = value }" />
</div>
</div>
</div>
</div>
</template>
-119
View File
@@ -1,119 +0,0 @@
<script setup lang="ts">
import {useRoute, useRouter} from "vue-router";
import {ref, watch} from "vue";
import {useDebounceFn, useEventListener} from "@vueuse/core";
import log from "loglevel";
import {ScriptEditor, useRuleBooksStore} from "@/rules";
import {PlusIcon, TrashIcon} from "@heroicons/vue/24/outline";
import {routeNames} from "@/routes";
import {SliderCheckbox} from "@/components";
const ruleBookId = ref<string>();
const name = ref<string>('');
const usedForAcquisitions = ref<boolean>(false);
const ledgerRefs = ref<string[]>([]);
const script = ref<string>('');
const ruleBooksStore = useRuleBooksStore();
const router = useRouter();
const save = async () => {
if (!ruleBookId.value) {
const created = await ruleBooksStore.create({
name: name.value,
usedForAcquisitions: usedForAcquisitions.value,
ledgerRefs: ledgerRefs.value,
script: script.value
})
await router.push({ name: routeNames.editRuleBook, params: {ruleBookId: created.ruleBookId}})
} else {
await ruleBooksStore.update(ruleBookId.value, {
name: name.value,
usedForAcquisitions: usedForAcquisitions.value,
ledgerRefs: ledgerRefs.value,
script: script.value
})
}
}
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
save();
}
}, {capture: true});
const addLedgerRef = () => {
ledgerRefs.value = [...ledgerRefs.value, '']
}
const updateLedgerRef = useDebounceFn((index: number, value: string) => {
ledgerRefs.value[index] = value;
}, 500);
const removeLedgerRef = (index: number) => {
ledgerRefs.value = ledgerRefs.value.toSpliced(index, 1)
}
watch(useRoute(), async route => {
if (route.params.ruleBookId) {
const promise = ruleBooksStore.refresh(); // FIXME don't call refresh
const id = typeof route.params.ruleBookId === 'string' ? route.params.ruleBookId : route.params.ruleBookId[0];
await promise;
const ruleBook = ruleBooksStore.findById(id);
ruleBookId.value = id;
name.value = ruleBook?.name ?? '';
usedForAcquisitions.value = ruleBook?.usedForAcquisitions ?? false;
ledgerRefs.value = [...(ruleBook?.ledgerRefs ?? [])];
script.value = ruleBook?.script ?? '';
log.info('Loaded rule book:', ruleBook);
} else {
ruleBookId.value = undefined;
name.value = '';
usedForAcquisitions.value = false;
ledgerRefs.value = [];
script.value = '';
log.info('No rule book to load');
}
}, { immediate: true })
</script>
<template>
<div class="flex flex-col mb-2 mt-4 h-[calc(100vh-4.5rem)]">
<div class="flex flex-col grow min-h-0">
<div class="flex grow border-b-1">
Name:
<input class="mb-2 ms-2" type="text" v-model="name" />
<label class="flex items-center ms-2 mb-2">
<SliderCheckbox class="me-2" v-model="usedForAcquisitions" />
Used for acquisitions
</label>
</div>
<div class="border-b-1">
Ledgers References:
<div class="flex flex-wrap items-center mt-2">
<div class="flex items-center mb-2" v-for="(ledgerRef, index) in ledgerRefs" :key="index">
<input class="me-1" type="text" :value="ledgerRef" @input="updateLedgerRef(index, ($event.target as HTMLInputElement).value)" />
<button class="btn-icon me-2" @click="removeLedgerRef(index)"><TrashIcon /></button>
</div>
<div class="flex items-center mb-2">
<button class="btn-icon" @click="addLedgerRef"><PlusIcon /></button>
</div>
</div>
</div>
<div class="flex flex-col grow min-h-0 border-b-1">
Script:
<ScriptEditor class="mt-2 mb-2" v-model="script" :ledgerRefs="ledgerRefs" />
</div>
</div>
<div class="mt-2 justify-end flex">
<div>
<button @click="save">Save</button>
</div>
</div>
</div>
</template>
@@ -1,46 +0,0 @@
<script setup lang="ts">
import {Character, CharacterLabel} from "@/characters";
import {PencilSquareIcon} from "@heroicons/vue/24/outline";
import {CharacterRuleBook, useCharacterRuleBooksStore} from "@/rules";
import {routeNames} from "@/routes.ts";
import {SortableHeader, useSort} from "@/components/table";
type CharacterRuleBookView = {
character: Character;
characterName: string;
ruleBookName: string;
}
const characterRuleBooksStore = useCharacterRuleBooksStore();
const { sortedArray, headerProps } = useSort<CharacterRuleBookView>(() => characterRuleBooksStore.characterRuleBooks.map((characterRuleBook: CharacterRuleBook): CharacterRuleBookView => ({
character: characterRuleBook.character,
characterName: characterRuleBook.character.name,
ruleBookName: characterRuleBook.ruleBook.name
})))
</script>
<template>
<div class="grid mb-2 mt-4">
<table>
<thead>
<tr>
<SortableHeader v-bind="headerProps" sortKey="characterName">Character</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="ruleBookName">Rule Book</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="buttons" unsortable />
</tr>
</thead>
<tbody>
<tr v-for="characterRuleBookView in sortedArray" :key="characterRuleBookView.character.characterId" >
<td>
<CharacterLabel :character="characterRuleBookView.character" />
</td>
<td>{{characterRuleBookView.ruleBookName}}</td>
<td class="text-right">
<RouterLink class="btn-icon" :to="{ name: routeNames.editCharacterRulebook, params: { characterId: characterRuleBookView.character.characterId } }"><PencilSquareIcon /></RouterLink>
</td>
</tr>
</tbody>
</table>
</div>
</template>
-35
View File
@@ -1,35 +0,0 @@
<script setup lang="ts">
import {DocumentDuplicateIcon, PencilSquareIcon, TrashIcon} from "@heroicons/vue/24/outline";
import {confirm} from "@/confirm";
import {RuleBook, useRuleBooksStore} from "@/rules";
import {routeNames} from "@/routes";
const ruleBooksStore = useRuleBooksStore();
const duplicate = async (ruleBook: RuleBook) => {
if (await confirm({title: "Duplicate Rule Book", message: `Duplicate ${ruleBook.name}?`, confirmLabel: "Duplicate"})) {
await ruleBooksStore.duplicate(ruleBook);
}
};
const remove = async (ruleBook: RuleBook) => {
if (await confirm({title: "Delete Rule Book", message: `Delete ${ruleBook.name}?`, confirmLabel: "Delete", danger: true})) {
await ruleBooksStore.remove(ruleBook.ruleBookId);
}
};
</script>
<template>
<div class="grid mb-2 mt-4">
<div class="flex justify-end border-b-1">
<RouterLink class="button mb-2 ms-2" :to="{ name: routeNames.newRuleBook}">New Rule Book</RouterLink>
</div>
<div v-for="ruleBook in ruleBooksStore.ruleBooks" :key="ruleBook.ruleBookId" class="flex items-center mt-2">
<span class="flex grow me-2">{{ruleBook.name}}</span>
<RouterLink class="btn-icon me-1" :to="{ name: routeNames.editRuleBook, params: { ruleBookId: ruleBook.ruleBookId } }"><PencilSquareIcon /></RouterLink>
<button class="btn-icon me-1" @click="duplicate(ruleBook)"><DocumentDuplicateIcon /></button>
<button class="btn-icon text-amber-700 hover:text-amber-600" @click="remove(ruleBook)"><TrashIcon /></button>
</div>
</div>
</template>
+16 -4
View File
@@ -1,19 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { useVModel } from '@vueuse/core';
const modelValue = defineModel({ default: false }); interface Props {
modelValue?: boolean;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false
});
const emit = defineEmits<Emits>();
const value = useVModel(props, 'modelValue', emit);
</script> </script>
<template> <template>
<label class="flex items-center relative w-max cursor-pointer select-none"> <label class="flex items-center relative w-max cursor-pointer select-none">
<input type="checkbox" class="appearance-none transition-colors cursor-pointer w-14 h-7 rounded-full" v-model="modelValue" /> <input type="checkbox" class="appearance-none transition-colors cursor-pointer w-14 h-7 rounded-full" v-model="value" />
<span class="absolute font-medium text-xs right-1"> Buy </span> <span class="absolute font-medium text-xs right-1"> Buy </span>
<span class="absolute font-medium text-xs right-8"> Sell </span> <span class="absolute font-medium text-xs right-8"> Sell </span>
<span class="w-7 h-7 right-7 absolute rounded-full transform transition-transform bg-slate-100" /> <span class="w-7 h-7 right-7 absolute rounded-full transform transition-transform bg-slate-100" />
</label> </label>
</template> </template>
<style scoped> <style scoped lang="postcss">
@reference "@/style.css";
input:checked ~ span:last-child { input:checked ~ span:last-child {
--tw-translate-x: 1.75rem; --tw-translate-x: 1.75rem;
} }
+14 -4
View File
@@ -1,12 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { evepraisalAxiosInstance } from '@/market/appraisal/evepraisal'; import { evepraisalAxiosInstance } from '@/market/appraisal/evepraisal';
import { useVModel } from '@vueuse/core';
interface Props { interface Props {
name: string; name: string;
modelValue?: string;
} }
const modelValue = defineModel({ default: '' }); interface Emits {
defineProps<Props>(); (e: 'update:modelValue', value: string): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: ''
});
const emit = defineEmits<Emits>();
const value = useVModel(props, 'modelValue', emit);
const loadFromId = async (e: Event) => { const loadFromId = async (e: Event) => {
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
@@ -21,7 +31,7 @@ const loadFromId = async (e: Event) => {
return; return;
} }
modelValue.value = JSON.stringify(response.data); value.value = JSON.stringify(response.data);
input.value = ''; input.value = '';
} }
</script> </script>
@@ -29,6 +39,6 @@ const loadFromId = async (e: Event) => {
<template> <template>
<div class="flex-1 mx-1"> <div class="flex-1 mx-1">
<span>{{ name }}</span><input type="text" class="ms-2" @change="loadFromId" placeholder="id evepraisal" /> <span>{{ name }}</span><input type="text" class="ms-2" @change="loadFromId" placeholder="id evepraisal" />
<textarea class="mt-1" v-model="modelValue" /> <textarea class="mt-1" v-model="value" />
</div> </div>
</template> </template>
+21 -28
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { SortableHeader, useSort, VirtualScrollTable } from '@/components/table'; import { SortableHeader, useSort } from '@/components/table';
import { formatIsk, percentFormater } from '@/formaters'; import { formatIsk, percentFormater } from '@/formaters';
import { MarketTypeLabel } from '@/market/type'; import { MarketTypeLabel } from '@/market/type';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
@@ -46,31 +46,24 @@ const { sortedArray, headerProps } = useSort(computed(() => props.result.map(r =
<input type="number" min="-100" max="100" step="1" v-model="threshold" /> <input type="number" min="-100" max="100" step="1" v-model="threshold" />
</div> </div>
</div> </div>
<VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem"> <table>
<template #default="{ list }"> <thead>
<thead> <tr>
<tr> <SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="market">Market</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="market">Market</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="materials">Materials</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="materials">Materials</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="ratio">Percent</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="ratio">Percent</SortableHeader> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> <tr v-for="r in sortedArray" :key="r.typeID" :class="{'line-green': r.ratio >= threshold / 100 }">
<tr v-for="r in list" :key="r.data.typeID" :class="{'line-green': r.data.ratio >= threshold / 100 }"> <td>
<td> <MarketTypeLabel :id="r.typeID" :name="r.name" />
<MarketTypeLabel :id="r.data.typeID" :name="r.data.name" /> </td>
</td> <td class="text-right">{{ formatIsk(r.market) }}</td>
<td class="text-right">{{ formatIsk(r.data.market) }}</td> <td class="text-right">{{ formatIsk(r.materials) }}</td>
<td class="text-right">{{ formatIsk(r.data.materials) }}</td> <td class="text-right">{{ percentFormater.format(r.ratio) }}</td>
<td class="text-right">{{ percentFormater.format(r.data.ratio) }}</td> </tr>
</tr> </tbody>
</tbody> </table>
</template>
<template #empty>
<div class="text-center mt-4">
<span>No items found</span>
</div>
</template>
</VirtualScrollTable>
</template> </template>
+5 -1
View File
@@ -1,3 +1,5 @@
import { marbasAxiosInstance } from "@/service";
export type ReprocessItemValues = { export type ReprocessItemValues = {
typeID: number; typeID: number;
name: string; name: string;
@@ -20,5 +22,7 @@ export const reprocess = async (items: string, minerals: string, efficiency?: nu
}; };
const source = JSON.stringify(sourceJson); const source = JSON.stringify(sourceJson);
return [] const response = await marbasAxiosInstance.post('/reprocess/', source, {params: {efficiency: efficiency ?? 0.55}});
return response.data;
}; };
+13 -54
View File
@@ -1,56 +1,15 @@
import {RouteRecordRaw} from 'vue-router'; import { RouteRecordRaw } from 'vue-router';
export const routeNames = {
home: 'home',
callback: 'callback',
viewLedger: 'view-ledger',
viewLedgerBalance: 'view-ledger-balance',
listLedgerTransactions: 'list-ledger-transactions',
listRuleBooks: 'list-rule-books',
newRuleBook: 'new-rule-book',
editRuleBook: 'edit-rule-book',
editCharacterRulebook: 'edit-character-rule-book',
marketTypes: 'market-types',
about: 'about',
} as const;
export const routes: RouteRecordRaw[] = [ export const routes: RouteRecordRaw[] = [
{path: '/', name: routeNames.home, component: () => import('@/pages/Index.vue')}, { path: '/', name: 'home', component: () => import('@/pages/Index.vue') },
{path: '/callback', name: routeNames.callback, component: () => import('@/pages/Index.vue')}, { path: '/callback', name: 'callback', component: () => import('@/pages/Index.vue') },
{ path: '/reprocess', component: () => import('@/pages/Reprocess.vue') },
{path: '/ledgers', component: () => import('@/pages/Ledgers.vue'), children: [ { path: '/market', component: () => import('@/pages/Market.vue'), children: [
{path: '', component: () => import('@/pages/ledger/ListLedgers.vue')}, { path: '', redirect: '/market/types' },
{path: ':ledgerId', component: () => import('./pages/ledger/ViewLedger.vue'), children: [ { path: 'types/:type?', name: 'market-types', component: () => import('@/pages/market/TypeInfo.vue') },
{path: '', name: routeNames.viewLedger, redirect: {name: routeNames.viewLedgerBalance}}, { path: 'scan', component: () => import('@/pages/market/Scan.vue') },
{path: 'balance', name: routeNames.viewLedgerBalance, component: () => import('@/pages/ledger/ViewLedgerBalance.vue')}, { path: 'acquisitions', component: () => import('@/pages/market/Acquisitions.vue') },
{path: 'transactions', name: routeNames.listLedgerTransactions, component: () => import('@/pages/ledger/ListLedgerTransactions.vue')}, ] },
]}, { path: '/tools', component: () => import('@/pages/Tools.vue') },
]}, { path: '/about', name: 'about', component: () => import('@/pages/About.vue') },
];
{path: '/rules', component: () => import('@/pages/Rules.vue'), children: [
{path: '', redirect: {name: routeNames.listRuleBooks}},
{path: '/rule-books', children: [
{path: '', name: routeNames.listRuleBooks, component: () => import('@/pages/rules/ListRuleBooks.vue')},
{path: 'new', name: routeNames.newRuleBook, component: () => import('@/pages/rules/EditRuleBook.vue')},
{path: ':ruleBookId', name: routeNames.editRuleBook, component: () => import('@/pages/rules/EditRuleBook.vue')},
]},
{path: '/characters/rules', children: [
{path: '', component: () => import('@/pages/rules/ListCharacterRuleBooks.vue')},
{path: '/characters/:characterId/rules', name: routeNames.editCharacterRulebook, component: () => import('@/pages/rules/EditCharacterRuleBook.vue')},
]}
]},
{path: '/market', component: () => import('@/pages/Market.vue'), children: [
{path: '', redirect: {name: routeNames.marketTypes}},
{path: 'types/:type?', name: routeNames.marketTypes, component: () => import('@/pages/market/TypeInfo.vue')},
{path: 'scan', component: () => import('@/pages/market/Scan.vue')},
{path: 'acquisitions', component: () => import('@/pages/market/Acquisitions.vue')},
]},
{path: '/reprocess', component: () => import('@/pages/Reprocess.vue')},
{path: '/tools', component: () => import('@/pages/Tools.vue')},
{path: '/characters', component: () => import('@/pages/Characters.vue')},
{path: '/about', name: routeNames.about, component: () => import('@/pages/About.vue')},
] as const;
-104
View File
@@ -1,104 +0,0 @@
<script setup lang="ts">
import * as monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
import {onBeforeUnmount, onMounted, ref, watch} from 'vue';
import {fetchScriptDefinitions} from './rules';
(self as unknown as { MonacoEnvironment: { getWorker(workerId: string, label: string): Worker } }).MonacoEnvironment = {
getWorker(_workerId: string, label: string) {
if (label === 'typescript' || label === 'javascript') {
return new tsWorker();
}
return new editorWorker();
}
};
let extraLibLoaded = false;
const loadScriptDefinitions = async () => {
if (extraLibLoaded) {
return;
}
try {
const definitions = await fetchScriptDefinitions();
monaco.typescript.javascriptDefaults.addExtraLib(definitions, 'ts:rule-runner.d.ts');
extraLibLoaded = true;
} catch {
// type definitions are optional — the editor still works without autocomplete
}
};
const props = defineProps<{ ledgerRefs?: string[] }>();
let ledgersLib: monaco.IDisposable | undefined;
const updateLedgerRefs = (refs: readonly string[]) => {
ledgersLib?.dispose();
const members = refs
.filter(ref => ref && ref !== 'system')
.map(ref => ` readonly ${JSON.stringify(ref)}: Ledger;`)
.join('\n');
ledgersLib = monaco.typescript.javascriptDefaults.addExtraLib(
`declare interface Ledgers {\n${members}\n}\n`,
'ts:rule-runner.ledgers.d.ts'
);
};
const model = defineModel<string>({default: ''});
const container = ref<HTMLElement>();
let editor: monaco.editor.IStandaloneCodeEditor | undefined;
onMounted(async () => {
await loadScriptDefinitions();
updateLedgerRefs(props.ledgerRefs ?? []);
if (!container.value) {
return;
}
editor = monaco.editor.create(container.value, {
value: model.value,
language: 'javascript',
theme: 'vs-dark',
automaticLayout: true,
minimap: {enabled: false},
scrollBeyondLastLine: false,
fontSize: 13,
tabSize: 2,
});
editor.onDidChangeModelContent(() => {
const value = editor!.getValue();
if (value !== model.value) {
model.value = value;
}
});
});
watch(model, value => {
if (editor && value !== editor.getValue()) {
editor.setValue(value ?? '');
}
});
watch(() => props.ledgerRefs, refs => updateLedgerRefs(refs ?? []), {deep: true});
onBeforeUnmount(() => {
editor?.dispose();
ledgersLib?.dispose();
});
</script>
<template>
<div ref="container" class="script-editor"></div>
</template>
<style scoped>
.script-editor {
width: 100%;
flex: 1 1 auto;
min-height: 12rem;
}
</style>
-3
View File
@@ -1,3 +0,0 @@
export * from "./rules";
export {default as ScriptEditor} from './ScriptEditor.vue';
-83
View File
@@ -1,83 +0,0 @@
import {characterRuleBookApi, ruleBookApi} from "@/mammon";
import {
CharacterRuleBookResponse,
CreateRuleBookRequest,
RuleBookResponse,
SetCharacterRuleBookRequest
} from "@/generated/mammon";
import {defineStore} from "pinia";
import {ref, triggerRef} from "vue";
export type RuleBook = RuleBookResponse;
export const useRuleBooksStore = defineStore('rule-books', () => {
const ruleBooks = ref<RuleBook[]>([]);
const addRuleBook = (ruleBook: RuleBook) => {
ruleBooks.value.push(ruleBook);
triggerRef(ruleBooks);
return ruleBook;
};
const replaceRuleBook = (ruleBook: RuleBook) => {
const index = ruleBooks.value.findIndex(rb => rb.ruleBookId === ruleBook.ruleBookId);
if (index !== -1) {
ruleBooks.value[index] = ruleBook;
}
triggerRef(ruleBooks);
return ruleBook;
};
const findById = (ruleBookId: string): RuleBook | undefined => ruleBooks.value.find(rb => rb.ruleBookId === ruleBookId);
const create = (ruleBook: CreateRuleBookRequest) => ruleBookApi.createRuleBook(ruleBook).then(response => addRuleBook(response.data));
const update = (ruleBookId: string, ruleBook: CreateRuleBookRequest) => ruleBookApi.updateRuleBook(ruleBookId, ruleBook).then(response => replaceRuleBook(response.data));
const duplicate = (ruleBook: RuleBook) => create({
name: `${ruleBook.name} (copy)`,
usedForAcquisitions: ruleBook.usedForAcquisitions,
ledgerRefs: [...ruleBook.ledgerRefs],
script: ruleBook.script,
});
const remove = (ruleBookId: string) => ruleBookApi.deleteRuleBook(ruleBookId).then(() => {
ruleBooks.value = ruleBooks.value.filter(rb => rb.ruleBookId !== ruleBookId);
});
const refresh = () => ruleBookApi.findAllRuleBooks().then(response => ruleBooks.value = response.data);
refresh();
return {ruleBooks, findById, create, update, duplicate, remove, refresh};
})
export type CharacterRuleBook = CharacterRuleBookResponse;
export const useCharacterRuleBooksStore = defineStore('character-rule-books', () => {
const characterRuleBooks = ref<CharacterRuleBook[]>([]);
const replaceCharacterRuleBook = (characterRuleBook: CharacterRuleBook) => {
const index = characterRuleBooks.value.findIndex(crb => crb.character.characterId === characterRuleBook.character.characterId);
if (index !== -1) {
characterRuleBooks.value[index] = characterRuleBook;
} else {
characterRuleBooks.value.push(characterRuleBook);
}
triggerRef(characterRuleBooks);
return characterRuleBook;
};
const findByCharacterId = (characterId: number): CharacterRuleBook | undefined => characterRuleBooks.value.find(crb => crb.character.characterId === characterId);
const setForCharacter = (characterId: number, ruleBook: SetCharacterRuleBookRequest) => characterRuleBookApi.setCharacterRuleBookForCharacter(characterId, ruleBook)
.then(response => replaceCharacterRuleBook(response.data));
const refresh = () => characterRuleBookApi.findAllCharacterRuleBooks().then(response => characterRuleBooks.value = response.data);
refresh();
return {characterRuleBooks, findByCharacterId, setForCharacter, refresh};
})
export const fetchScriptDefinitions = (): Promise<string> =>
ruleBookApi.getScriptDefinitions({responseType: 'text'}).then(response => response.data);
+59 -5
View File
@@ -1,15 +1,69 @@
import { AxiosInstance } from 'axios'; import { useAuthStore } from '@/auth';
import axios, { AxiosInstance } from 'axios';
import log from 'loglevel'; import log from 'loglevel';
export const logResource = (a: AxiosInstance) => { export const logResource = (a: AxiosInstance) => {
a.interceptors.response.use(r => { a.interceptors.response.use(r => {
log.debug(`[${r.config.method?.toUpperCase()}] ${r.config.url}`); log.debug(`[${r.config.method?.toUpperCase()}] ${r.config.url}`);
return r; return r;
}, e => { }, e => {
if (!e?.config) { log.error(`[${e.config.method?.toUpperCase()}] ${e.config.url} failed with ${e.response?.status} ${e.response?.statusText}`);
log.error(e.message, e);
}
log.error(`[${e.config?.method?.toUpperCase()}] ${e.config?.url} failed with ${e.response?.status} ${e.response?.statusText}`, e);
return Promise.reject(e); return Promise.reject(e);
}); });
} }
export const marbasAxiosInstance = axios.create({
baseURL: import.meta.env.VITE_MARBAS_URL,
headers: {
'Accept': 'application/json',
"Content-Type": "application/json"
},
})
marbasAxiosInstance.interceptors.request.use(r => {
if (!r.params?.page_size) {
r.params = { ...r.params, page_size: 250 };
}
return r;
})
logResource(marbasAxiosInstance)
marbasAxiosInstance.interceptors.response.use(async r => {
const next = r.data?.next;
let results = r.data?.results;
if (next) {
results = results.concat((await marbasAxiosInstance.request({
...r.config,
url: next,
baseURL: '',
})).data);
}
if (results) {
r.data = results;
}
return r;
})
marbasAxiosInstance.interceptors.request.use(r => {
const authStore = useAuthStore();
if (!authStore.isLoggedIn) {
throw new Error("Not logged in");
}
const accessToken = authStore.accessToken;
if (accessToken) {
r.headers.Authorization = `Bearer ${accessToken}`;
}
return r;
})
export const esiAxiosInstance = axios.create({
baseURL: import.meta.env.VITE_ESI_URL,
headers: {
'Accept': 'application/json',
"Content-Type": "application/json",
"User-Agent": import.meta.env.VITE_ESI_USER_AGENT
},
})
logResource(esiAxiosInstance)
+50 -53
View File
@@ -1,73 +1,70 @@
<script setup lang="ts"> <script setup lang="ts">
import {Dropdown} from '@/components'; import { useAuthStore } from '@/auth';
import {RouterLink} from 'vue-router'; import { Dropdown } from '@/components';
import {routeNames} from '@/routes'; import { RouterLink } from 'vue-router';
const links = [ const links = [
{name: "Market", path: "/market"}, { name: "Market", path: "/market" },
{name: "Ledger", path: "/ledgers"}, { name: "Reprocess", path: "/reprocess" },
{name: "Rules", path: "/rules"}, { name: "Tools", path: "/tools" }
{name: "Reprocess", path: "/reprocess"},
{name: "Tools", path: "/tools"}
]; ];
const authStore = useAuthStore();
const logout = async () => { const logout = async () => {
await authStore.logout();
} }
</script> </script>
<template> <template>
<aside class="fixed top-0 left-0 w-64 h-screen transition-transform -translate-x-full sm:translate-x-0"> <aside class="fixed top-0 left-0 w-64 h-screen transition-transform -translate-x-full sm:translate-x-0">
<div class="h-full px-3 py-4 overflow-y-auto bg-slate-700 flex flex-col"> <div class="h-full px-3 py-4 overflow-y-auto bg-slate-700 flex flex-col">
<div class="mb-2 border-b-2 border-emerald-500"> <div class="mb-2 border-b-2 border-emerald-500">
<Dropdown class="mb-2 user-dropdown"> <Dropdown class="mb-2 user-dropdown">
<template #button> <template #button>
<span>NAME</span> <span>{{ authStore.username }}</span>
</template> </template>
<ul> <ul>
<li> <li>
<RouterLink class="sidebar-button py-0.5 px-2" to="/characters">Characters</RouterLink> <RouterLink class="sidebar-button py-0.5 px-2" :to="{name: 'about'}">About EVE Online</RouterLink>
</li> </li>
<li> <li>
<RouterLink class="sidebar-button py-0.5 px-2" :to="{name: routeNames.about}">About EVE Online</RouterLink> <a class="sidebar-button py-0.5 px-2 text-amber-700" @click="logout">Logout</a>
</li> </li>
<li> </ul>
<a class="sidebar-button py-0.5 px-2 text-amber-700" @click="logout">Logout</a> </Dropdown>
</li> </div>
</ul> <ul class="space-y-2 font-medium">
</Dropdown> <li v-for="link in links" :key="link.name">
</div> <RouterLink :to="link.path" class="sidebar-button p-2">
<ul class="space-y-2 font-medium"> <span>{{ link.name }}</span>
<li v-for="link in links" :key="link.name"> </RouterLink>
<RouterLink :to="link.path" class="sidebar-button p-2"> </li>
<span>{{ link.name }}</span> </ul>
</RouterLink> </div>
</li> </aside>
</ul>
</div>
</aside>
</template> </template>
<style scoped> <style scoped lang="postcss">
@reference "@/style.css";
.sidebar-button { .sidebar-button {
@apply flex items-center rounded-md hover:bg-slate-800 cursor-pointer; @apply flex items-center rounded-md hover:bg-slate-800 cursor-pointer;
} }
.router-link-active { .router-link-active {
@apply bg-emerald-500 hover:bg-emerald-700; @apply bg-emerald-500 hover:bg-emerald-700;
} }
.user-dropdown { .user-dropdown {
@apply w-full; @apply w-full;
} :deep(>div) {
@apply w-full;
.user-dropdown :deep(>button) { >div {
@apply bg-slate-700 hover:bg-slate-800 border-none flex items-center w-full; @apply w-full bg-slate-800;
} }
}
.user-dropdown.dropdown-open :deep(>button) { :deep(>button) {
@apply bg-slate-800 rounded-b-none; @apply bg-slate-700 hover:bg-slate-800 border-none flex items-center w-full;
}
&.dropdown-open:deep(>button) {
@apply bg-slate-800 rounded-b-none;
}
} }
</style> </style>
+27 -50
View File
@@ -1,15 +1,9 @@
@import "tailwindcss"; @tailwind base;
@tailwind components;
@tailwind utilities;
@custom-variant search-cancel (&::-webkit-search-cancel-button);
@utility btn-icon {
@apply p-0 border-none bg-transparent hover:text-slate-400 hover:bg-transparent cursor-pointer;
> svg {
@apply w-6 h-6;
}
}
@layer base { @layer base {
span, table, input, th, tr, td, button, a.button, div, hr { span, table, input, th, tr, td, button, div, hr {
@apply border-slate-600 text-slate-100 placeholder-slate-400; @apply border-slate-600 text-slate-100 placeholder-slate-400;
} }
@@ -17,51 +11,39 @@
@apply bg-slate-800; @apply bg-slate-800;
} }
button, a.button { button {
@apply py-0.5 px-2 border rounded bg-slate-600 hover:bg-slate-700; @apply py-0.5 px-2 border rounded bg-slate-600 hover:bg-slate-700;
} }
input, select { input {
@apply border bg-slate-500 rounded px-1; @apply border bg-slate-500 rounded px-1;
} }
option {
@apply bg-slate-500;
}
textarea { textarea {
@apply border rounded bg-slate-500 w-full; @apply border rounded bg-slate-500 w-full;
} }
table, .table { table {
@apply table-auto border-collapse border-slate-500 w-full; @apply table-auto border-collapse border-slate-500 w-full;
}
.table-header {
@apply table-cell;
}
.table-cell {
@apply pt-px pb-px;
}
th, .table-header {
@apply border bg-slate-600 px-1;
}
td, .table-cell {
@apply border px-1;
}
tr, .table-row {
@apply hover:bg-slate-900;
&.line-red { th {
@apply bg-amber-900 hover:bg-amber-950; @apply border bg-slate-600 px-1;
} }
&.line-blue { td {
@apply bg-sky-600 hover:bg-sky-800; @apply border px-1;
} }
&.line-green { tr {
@apply bg-emerald-500 hover:bg-emerald-600; @apply hover:bg-slate-900;
}
}
tfoot>tr>td {
@apply font-semibold;
}
&.line-red {
@apply bg-amber-900 hover:bg-amber-950;
}
&.line-blue {
@apply bg-sky-600 hover:bg-sky-800;
}
&.line-green {
@apply bg-emerald-500 hover:bg-emerald-600;
}
}
}
::-webkit-scrollbar { ::-webkit-scrollbar {
@apply w-3; @apply w-3;
@@ -78,14 +60,9 @@
} }
.btn-icon { .btn-icon {
@apply btn-icon; @apply p-0 border-none bg-transparent hover:text-slate-400 hover:bg-transparent;
} > svg {
@apply w-6 h-6;
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;
} }
} }
} }
-78
View File
@@ -1,78 +0,0 @@
<script setup lang="ts">
import {TransferTypes} from "@/transaction/transaction.ts";
import {LedgerLabel, systemLedger, useLedgersStore} from "@/ledger";
import {getMarketType, IskLabel, MarketTypeLabel} from "@/market";
import {computedAsync} from "@vueuse/core";
import {TransferResponse} from "@/generated/mammon";
type TransferWithValue = TransferResponse & { marketTypeId: number; };
interface Props {
transfers?: TransferResponse[]
}
const props = defineProps<Props>();
const ledgersStore = useLedgersStore();
const sortedArray = computedAsync(async () => {
if (!props.transfers) {
return [];
}
return (await Promise.all(props.transfers.map(async (transfer: TransferWithValue, index) => {
const fromLedger = ledgersStore.findById(transfer.fromLedgerId) ?? systemLedger
const toLedger = ledgersStore.findById(transfer.toLedgerId) ?? systemLedger
const item = transfer.marketTypeId ? await getMarketType(transfer.marketTypeId) : undefined;
return {
...transfer,
order: index,
fromLedger,
toLedger,
itemName: item ? item.name : '',
fromLedgerName: fromLedger.name,
toLedgerName: toLedger.name
}
}))).sort((a, b) => a.order - b.order)
}, []);
</script>
<template>
<div>
<table>
<thead>
<tr>
<th>From</th>
<th>To</th>
<th>Item</th>
<th>Quantity/Amount</th>
</tr>
</thead>
<tbody>
<tr v-for="t in sortedArray">
<td>
<LedgerLabel :ledger="t.fromLedger" />
</td>
<td>
<LedgerLabel :ledger="t.toLedger" />
</td>
<template v-if="t.type === TransferTypes.Item">
<td>
<MarketTypeLabel :id="t.marketTypeId" :name="t.itemName" />
</td>
<td class="text-right">{{ t.quantity }}</td>
</template>
<template v-else-if="t.type === TransferTypes.Isk">
<td colspan="2" class="text-right">
<IskLabel :amount="t.amount" :colored="false" />
</td>
</template>
</tr>
</tbody>
</table>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More