diff --git a/app/assets/styles/button.css b/app/assets/styles/button.css index ebc7ca1..46e25a1 100755 --- a/app/assets/styles/button.css +++ b/app/assets/styles/button.css @@ -13,9 +13,51 @@ transition: var(--transition-default); outline: none; border: none; - background: transparent; + background: var(--background); color: var(--color); + &.raised { + box-shadow: var(--box-shadow-z2); + padding: .5em 1.5em; + border-radius: var(--radius-default); + + &.danger { + --background: var(--color-red); + --color: var(--color-white); + } + } + + &.text { + --background: transparent; + --color: var(--color-black); + padding: .5em 1.5em; + border-radius: var(--radius-default); + + &:hover { + --background: rgba(0, 0, 0, 0.05); + } + + &.white { + --color: var(--color-white); + } + + &.danger { + --color: var(--color-red); + + &:hover { + --background: rgba(255, 0, 0, 0.1); + } + } + } + + &.round { + display: flex; + justify-content: center; + align-items: center; + border-radius: 100%; + padding: .5rem; + } + &.cta { background: var(--background); color: var(--color); diff --git a/app/assets/styles/formInput.css b/app/assets/styles/formInput.css index 44d228d..c207605 100755 --- a/app/assets/styles/formInput.css +++ b/app/assets/styles/formInput.css @@ -1,44 +1,52 @@ .Input { - position: relative; - flex: 25% 1 0; - border: 2px solid var(--color-blue); - border-radius: var(--radius-default); - overflow: hidden; - transition: var(--transition-default); - outline: 0 solid var(--color-white); + &.error { + & .input-wrapper { + border-color: var(--color-red); + outline-width: 2px; + } - & label { - position: absolute; - left: .5rem; - font-size: 1.5em; - top: .7rem; - transition: var(--transition-default); - } - - & input { - all: unset; - width: calc(100% - 1rem); - font-size: 1.2em; - padding: 1.3rem .5rem .5rem .5rem; - background: var(--color-white); - - &[type="number"] { - text-align: right; + & span { + color: var(--color-red); } } - &:has(input:invalid) { - border-color: var(--color-red); - outline-width: 2px; + & span { + font-size: .65em; } - & input:focus, - & input:not(:placeholder-shown) { - & + label { - color: var(--color-main); - font-size: 1em; + & .input-wrapper { + position: relative; + flex: 25% 1 0; + border: 2px solid var(--color-blue); + border-radius: var(--radius-default); + overflow: hidden; + transition: var(--transition-default); + outline: 0 solid var(--color-white); + + & label { + position: absolute; + font-size: .8em; top: .3rem; - right: .5rem; + left: .5rem; + transition: var(--transition-default); + } + + & input { + all: unset; + width: calc(100% - 1rem); + padding: 1.3rem .5rem .5rem .5rem; + background: var(--color-white); + + &[type="number"] { + text-align: right; + } + } + + & input:focus, + & input:not(:placeholder-shown) { + & + label { + color: var(--color-main); + } } } } \ No newline at end of file diff --git a/app/assets/styles/general.css b/app/assets/styles/general.css index c206ecb..f6c2d70 100755 --- a/app/assets/styles/general.css +++ b/app/assets/styles/general.css @@ -10,6 +10,7 @@ --color-blue: #2e86de; --color-blue-dark: #1b4b7f; --color-grey: #c7c7c7; + --color-black: #333; --color-orange: #DE9C2F; @@ -127,4 +128,41 @@ body { .gap-default { gap: 1rem; +} + +.bg-blue { + background: var(--color-blue); +} + +.bg-white { + background: var(--color-white); +} + +.padding { + gap: 1rem; + padding: var(--padding-default); +} + +dialog { + top: 50%; + left: 50%; + width: 100vw; + transform: translate(-50%, -50%); + border: none; + border-radius: var(--radius-default); + + font-size: 1rem; + + & header { + justify-content: space-between; + align-items: center; + } + + & footer { + justify-content: space-between; + } + + &::backdrop { + background: rgba(0, 0, 0, 0.5); + } } \ No newline at end of file diff --git a/app/assets/styles/priceCard.css b/app/assets/styles/priceCard.css index 47acd68..9eb4269 100755 --- a/app/assets/styles/priceCard.css +++ b/app/assets/styles/priceCard.css @@ -11,83 +11,66 @@ opacity: 0; } - &.folded { - grid-template-rows: auto 0fr auto; - } - & > header { color: var(--color-white); display: flex; justify-content: space-between; align-items: center; - font-size: 1.3em; + font-size: 1.5rem; + + & .icon { + font-size: 1.2rem; + cursor: pointer; + } + + & > .name-price { + display: flex; + gap: .5rem; + + & > span:nth-child(2)::before { + content: '•'; + margin-right: .5rem; + opacity: .5; + } + } & > .Button { color: var(--color-white); - border: 2px solid var(--color-white); } } - & aside { - overflow: hidden; - } - - & footer { - display: flex; - justify-content: space-between; - align-items: center; - - & > .Button.delete { - scale: 0; - } - - & > .Button.deletable { - scale: 1; - } - } - - & .padding { - gap: 1rem; - padding: var(--padding-default); - } - - & .bg-blue { - background: var(--color-blue); - } - - & .bg-white { - background: var(--color-white); - } - & .wrapper { display: flex; flex-direction: row; width: 100%; gap: 1rem; justify-content: space-between; + padding-top: 0; & > * { - flex-basis: 25%; - flex-grow: 1; + flex-basis: 10%; + flex-grow: 0; } & > .info { + color: var(--color-white); align-items: center; + gap: .25rem; & > .icon { - color: var(--color-blue-light); font-size: 2rem; padding: .2rem; } & > .price { - font-size: 1.2rem; + display: flex; + align-items: center; + gap: .5rem; } & > .pro { - font-size: .8rem; + font-size: .6rem; font-weight: bold; - color: var(--color-main-light); } } } diff --git a/app/components/Pp/DeleteDialog.vue b/app/components/Pp/DeleteDialog.vue new file mode 100644 index 0000000..dd24927 --- /dev/null +++ b/app/components/Pp/DeleteDialog.vue @@ -0,0 +1,39 @@ +<template> + <dialog + ref="dialog" + closedby="any" + > + <form method="dialog"> + <header class="flex-row padding"> + Wirklich löschen? + <PpButton class="round text"> + <Icon name="uil:times" mode="svg" /> + </PpButton> + </header> + <main> + <div class="padding flex-col"> + <p>Bist du dir sicher, dass du diesen Eintrag löschen möchtest?</p> + </div> + </main> + <footer class="flex-row padding"> + <PpButton class="text"> + <span>Abbrechen</span> + </PpButton> + <PpButton class="danger raised" @click="$emit('delete')"> + <span>Löschen</span> + </PpButton> + </footer> + </form> + </dialog> +</template> + +<script setup lang="ts"> +type Props = { + currentCardIndex : number +} + +defineProps<Props>() +defineEmits(['delete']) + + +</script> \ No newline at end of file diff --git a/app/components/Pp/FormInput.vue b/app/components/Pp/FormInput.vue index 66c8a94..cd1ec71 100755 --- a/app/components/Pp/FormInput.vue +++ b/app/components/Pp/FormInput.vue @@ -1,17 +1,20 @@ <template> - <div class="Input flex-col"> - <input + <div class="Input"> + <div class="input-wrapper flex-col"> + <input v-model="text" :type="type" - :id="makeId()" + :id="id" :step="step" :min="min" :max="max" :required="required" placeholder=" " @blur="emit('blur')" - /> - <label :for="makeId()">{{ label }}</label> + /> + <label :for="id">{{ label }}</label> + </div> + <span v-if="message">{{ message }}</span> </div> </template> @@ -22,9 +25,9 @@ type Props = { min ?: number step ?: number required ?: boolean + message ?: string label : string id : string - uid : string } const { @@ -32,15 +35,9 @@ const { required = false, step = 0.01, min = 1, - max, - label, - id, - uid, } = defineProps<Props>() const emit = defineEmits(['blur']) const text = defineModel() - -const makeId = () => `${id}_${uid}` </script> diff --git a/app/components/Pp/PriceCard.vue b/app/components/Pp/PriceCard.vue index 0b495bb..7df8199 100755 --- a/app/components/Pp/PriceCard.vue +++ b/app/components/Pp/PriceCard.vue @@ -1,64 +1,47 @@ <template> - <form + <article ref="root" - class="PriceCard card" - :class="{ folded, deleting }" - @submit.prevent="() => {}" + class="PriceCard card bg-blue" + :class="{ deleting }" > - <header class="padding bg-blue"> - {{ card.name || 'Kein Name' }} - <PpButton - class="icon-button bg-main" - @click="folded = !folded" - > - <Icon :name="folded ? 'uil:sort' : 'uil:sorting'" mode="svg" /> - </PpButton> - </header> - <aside> - <div class="input-wrapper padding bg-blue flex-col"> - <div class="wrapper"> - <PpFormInput v-model="card.name" label="Name" id="n" :uid="card.uuid" type="text" @blur="update" /> - <PpFormInput v-model="card.price" label="Preis" id="p" :uid="card.uuid" type="number" :min="0.01" @blur="calculate" /> - </div> - <div class="wrapper"> - <PpFormInput v-model="card.roles" label="Rollen" id="r" :uid="card.uuid" type="number" :max="150" @blur="calculate" /> - <PpFormInput v-model="card.sheets" label="Blätter" id="b" :uid="card.uuid" type="number" :max="500" @blur="calculate" /> - <PpFormInput v-model="card.layers" label="Lagen" id="l" :uid="card.uuid" type="number" :max="10" @blur="calculate" /> - </div> + <header class="padding"> + <div class="name-price"> + <span>{{ card.name || 'Kein Name' }}</span> + <span>{{ intl.format(+replaceComma(card.price))}}</span> </div> - </aside> - <main class="wrapper padding bg-white"> + <div class="flex-row gap-default"> + <PpButton class="icon-button" @click="update()"> + <Icon class="icon" name="uil:pen" mode="svg" /> + </PpButton> + <PpButton class="icon-button" @click="deleteCard()"> + <Icon class="icon" name="uil:times" mode="svg" /> + </PpButton> + </div> + </header> + <main class="wrapper padding"> <div class="info flex-col"> - <Icon class="icon" name="uil:toilet-paper" mode="svg" /> - <span class="price">{{ intl.format(ppr) }}</span> + <div class="price"> + <Icon class="icon" name="uil:toilet-paper" mode="svg" /> + <span class="value">{{ intl.format(card.ppr) }}</span> + </div> <span class="pro">Pro 1</span> </div> <div class="info flex-col"> - <Icon class="icon" name="uil:file-landscape" mode="svg" /> - <span class="price">{{ intl.format(pps) }}</span> + <div class="price"> + <Icon class="icon" name="uil:file-landscape" mode="svg" /> + <span class="value">{{ intl.format(card.pps) }}</span> + </div> <span class="pro">Pro 10</span> </div> <div class="info flex-col"> - <Icon class="icon" name="uil:layer-group" mode="svg" /> - <span class="price">{{ intl.format(ppl) }}</span> + <div class="price"> + <Icon class="icon" name="uil:layer-group" mode="svg" /> + <span class="value">{{ intl.format(card.ppl) }}</span> + </div> <span class="pro">Pro 100</span> </div> </main> - <footer class="padding bg-blue flex-row"> - <PpButton - class="delete" - :class="{ deletable }" - @click="deleteCard" - > - <Icon name="uil:trash" mode="svg" /> - Entfernen - </PpButton> - <PpButton v-if="false" class="cta white"> - <Icon name="uil:qrcode-scan" mode="svg" /> - Scan - </PpButton> - </footer> - </form> + </article> </template> <script setup lang="ts"> @@ -73,43 +56,14 @@ const { card } = defineProps<Props>() const emit = defineEmits(['remove', 'update']) const root = ref<HTMLElement>() -const folded = ref<boolean>(false) const deleting = ref<boolean>(false) -const ppr = ref(card.ppr) -const pps = ref(card.pps) -const ppl = ref(card.ppl) - const intl = Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR', }) -const calculate = () => { - if (!card.price || !card.roles) return - ppr.value = card.price / card.roles +const update = () => emit('update') - if (!card.sheets) { - update() - return - } - pps.value = (ppr.value / card.sheets) * 10 - - if(!card.layers) { - update() - return - } - ppl.value = (pps.value / card.layers) * 10 - - update() -} - -const update = () => emit('update', { ...card, ppr: ppr.value, pps: pps.value, ppl: ppl.value }) - -const deleteCard = async () => { - root.value?.addEventListener('transitionend', () => emit('remove')) - deleting.value = true -} - -onMounted(() => folded.value = !!card.price) +const deleteCard = () => emit('remove') </script> diff --git a/app/components/Pp/PriceCardDialog.vue b/app/components/Pp/PriceCardDialog.vue new file mode 100644 index 0000000..00dd42f --- /dev/null +++ b/app/components/Pp/PriceCardDialog.vue @@ -0,0 +1,129 @@ +<template> + <dialog + ref="dialog" + closedby="any" + > + <form method="dialog"> + <header class="flex-row padding"> + {{ cardLabel }} + <PpButton class="round text"> + <Icon name="uil:times" mode="svg" /> + </PpButton> + </header> + </form> + <main v-if="currentCard"> + <div class="padding flex-col"> + <div class="flex-row gap-default"> + <PpFormInput + v-model="currentCard.name" + id="card_name" + label="Name" + :class="{'error': !validFields.name }" + :message="!validFields.name ? 'Feld darf nicht leer sein.' : ''" + /> + <PpFormInput + v-model="currentCard.price" + id="card_price" + label="Preis" + :class="{'error': !validFields.price }" + :message="!validFields.price ? 'Muss eine Zahl sein.' : ''" + /> + </div> + <div class="flex-row gap-default"> + <PpFormInput + v-model="currentCard.roles" + id="card_roles" + label="Rollen" + :class="{'error': !validFields.roles }" + :message="!validFields.roles ? 'Muss eine Ganzzahl sein.' : ''" + /> + <PpFormInput + v-model="currentCard.sheets" + id="card_sheets" + label="Blätter" + :class="{'error': !validFields.sheets }" + :message="!validFields.sheets ? 'Muss eine Ganzzahl sein.' : ''" + /> + <PpFormInput + v-model="currentCard.layers" + id="card_layers" + label="Lagen" + :class="{'error': !validFields.layers }" + :message="!validFields.layers ? 'Muss eine Ganzzahl sein.' : ''" + /> + </div> + </div> + </main> + <footer class="flex-row padding"> + <form method="dialog"> + <PpButton class="danger text"> + <span>Abbrechen</span> + </PpButton> + </form> + <PpButton class="raised" @click="validate"> + <span>{{ cardLabel }}</span> + </PpButton> + </footer> + </dialog> +</template> + +<script setup lang="ts"> +import type { Card } from '../../../shared/Card' + +type Props = { + currentCardIndex : number + currentCard ?: Card +} + +const { currentCardIndex, currentCard } = defineProps<Props>() +const emit = defineEmits(['update']) + +const dialog = useTemplateRef<HTMLDialogElement>('dialog') +const cardLabel = computed(() => currentCardIndex > -1 ? 'Bearbeiten' : 'Hinzufügen') + +const checkPrice = () => { + if (!currentCard) { return false } + if (currentCard.price.length === 0) { return false } + const price = +replaceComma(currentCard.price) + return !isNaN(price) +} + +const checkIfInteger = (toBeNumber : string) => { + if (toBeNumber.length === 0) { return false } + if (toBeNumber.includes(',') || toBeNumber.includes('.')) { return false } + return !isNaN(+toBeNumber) +} + +const validFields = reactive({ + name: true, + price: true, + roles: true, + sheets: true, + layers: true, +}) + +const validate = () => { + if (!currentCard) { return } + + validFields.name = currentCard.name.length > 0 + validFields.price = checkPrice() + validFields.roles = checkIfInteger(currentCard.roles) + validFields.sheets = checkIfInteger(currentCard.sheets) + validFields.layers = checkIfInteger(currentCard.layers) + + if (Object.values(validFields).every(value => value)) { + emit('update') + dialog.value?.close() + } +} + +onMounted(() => { + dialog.value?.addEventListener('close', () => { + validFields.name = true + validFields.price = true + validFields.roles = true + validFields.sheets = true + validFields.layers = true + }) +}) +</script> \ No newline at end of file diff --git a/app/pages/index.vue b/app/pages/index.vue index cc1abb7..84debe6 100755 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -1,4 +1,15 @@ <template> + <PpDeleteDialog + ref="deleteModal" + :current-card-index="currentCardIndex" + @delete="removeCard(currentCardIndex)" + /> + <PpPriceCardDialog + ref="modal" + :current-card="currentCard" + :current-card-index="currentCardIndex" + @update="updateCard()" + /> <section class="content flex-col"> <aside class="filter-bar"> <PpButtonGroup @@ -12,8 +23,8 @@ :key="card.uuid" :deletable="cards.length > 1" :card="card" - @update="newCard => updateCard(newCard, index)" - @remove="removeCard(card)" + @update="openModal(false, index)" + @remove="openDeleteModal()" /> </div> </section> @@ -27,7 +38,7 @@ aria-hidden="true" /> </PpButton> - <PpButton class="mini-button text-white" @click="addCard"> + <PpButton class="mini-button text-white" @click="openModal(true, -1)"> <Icon class="icon" name="uil:plus" mode="svg" /> <span>Hinzufügen</span> </PpButton> @@ -37,17 +48,22 @@ <script setup lang="ts"> import type { Card } from '../../shared/Card' import type { Button } from '../../shared/ButtonGroup' +import { PpPriceCardDialog, PpDeleteDialog } from '#components' const currentSort = ref(0) const isDirty = ref(false) +const currentCard = ref<Card>() +const currentCardIndex = ref<number>(-1) +const modal = useTemplateRef<typeof PpPriceCardDialog>('modal') +const deleteModal = useTemplateRef<typeof PpDeleteDialog>('deleteModal') const createCard = (uuid : string) : Card => ({ uuid, name: '', - price: 0, - roles: 0, - sheets: 0, - layers: 0, + price: '', + roles: '', + sheets: '', + layers: '', ppr: 0, pps: 0, ppl: 0, @@ -57,23 +73,51 @@ const cards = useState('cards', () => [ createCard(crypto.randomUUID()), ]) -const addCard = () => { - cards.value.unshift(createCard(crypto.randomUUID())) - isDirty.value = true -} - -const removeCard = (card : Card) => { - cards.value = cards.value.filter(element => element.uuid !== card.uuid) +const addCard = (card : Card) => { + const price = calculate(card) + cards.value.unshift({ ...card, ...price }) isDirty.value = true updateLocalStorage() } -const updateCard = (card : Card, index : number) => { - cards.value[index] = card +const removeCard = (index : number) => { + cards.value.splice(index, 1) isDirty.value = true updateLocalStorage() } +const updateCard = () => { + if (currentCardIndex.value === -1) { + addCard(currentCard.value!) + return + } + + const price = calculate(currentCard.value!) + const newCard = { ...currentCard.value!, ...price } + cards.value.splice(currentCardIndex.value, 1, newCard) + isDirty.value = true + updateLocalStorage() +} + +const openModal = (createNew : boolean, index : number) => { + if (createNew) { + currentCardIndex.value = -1 + currentCard.value = createCard(crypto.randomUUID()) + + modal.value?.$el.showModal() + return + } + + currentCardIndex.value = index + currentCard.value = { ...cards.value[index]! } + + modal.value?.$el.showModal() + return +} + +const openDeleteModal = () => { + deleteModal.value?.$el.showModal() +} const updateLocalStorage = () => { localStorage.setItem('cards', JSON.stringify(cards.value)) @@ -120,6 +164,14 @@ const sort = (index : number) => { isDirty.value = false } +const calculate = (card : Card) => { + const ppr = +replaceComma(card.price) / +card.roles + const pps = (ppr / +card.sheets) * 10 + const ppl = (pps / +card.layers) * 10 + + return { ppr, pps, ppl } +} + onMounted(() => { const cardsFromStorage = JSON.parse(localStorage.getItem('cards') ?? '[]') cards.value = cardsFromStorage.length !== 0 ? cardsFromStorage : cards.value diff --git a/app/utils/number.ts b/app/utils/number.ts new file mode 100644 index 0000000..388e4cb --- /dev/null +++ b/app/utils/number.ts @@ -0,0 +1 @@ +export const replaceComma = (value: string | number) => `${value}`.replace(',', '.'); \ No newline at end of file diff --git a/package.json b/package.json index d90bf07..16aac4b 100755 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev:expose": "nuxt dev --host", "generate": "nuxt generate", "preview": "nuxt preview", + "prepare": "nuxt prepare", "postinstall": "nuxt prepare" }, "dependencies": { diff --git a/shared/Card.ts b/shared/Card.ts index 4d84b22..e10e5f6 100644 --- a/shared/Card.ts +++ b/shared/Card.ts @@ -1,10 +1,10 @@ export type Card = { uuid: string name: string - price: number - roles: number - sheets: number - layers: number + price: string + roles: string + sheets: string + layers: string ppr: number pps: number ppl: number