feat: new dashboard implementation (#168)

* wip: charts.js experimental work

* update lock file

* wip: frontend redesign

* wip: more UI fixes for consistency across themes

* cleanup

* improve UI log

* style updates

* fix lint errors
This commit is contained in:
Hayden 2022-12-29 17:19:15 -08:00 committed by GitHub
parent a3954dab0f
commit 6a8a25e3f8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1690 additions and 296 deletions

View file

@ -20,5 +20,5 @@
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint" "editor.defaultFormatter": "dbaeumer.vscode-eslint"
}, },
"eslint.format.enable": true,
} }

View file

@ -29,7 +29,7 @@ func (ctrl *V1Controller) HandleItemsGetAll() server.HandlerFunc {
return repo.ItemQuery{ return repo.ItemQuery{
Page: queryIntOrNegativeOne(params.Get("page")), Page: queryIntOrNegativeOne(params.Get("page")),
PageSize: queryIntOrNegativeOne(params.Get("perPage")), PageSize: queryIntOrNegativeOne(params.Get("pageSize")),
Search: params.Get("q"), Search: params.Get("q"),
LocationIDs: queryUUIDList(params, "locations"), LocationIDs: queryUUIDList(params, "locations"),
LabelIDs: queryUUIDList(params, "labels"), LabelIDs: queryUUIDList(params, "labels"),

View file

@ -1665,6 +1665,10 @@ const docTemplate = `{
"name": { "name": {
"type": "string" "type": "string"
}, },
"purchasePrice": {
"type": "string",
"example": "0"
},
"quantity": { "quantity": {
"type": "integer" "type": "integer"
}, },

View file

@ -1657,6 +1657,10 @@
"name": { "name": {
"type": "string" "type": "string"
}, },
"purchasePrice": {
"type": "string",
"example": "0"
},
"quantity": { "quantity": {
"type": "integer" "type": "integer"
}, },

View file

@ -202,6 +202,9 @@ definitions:
x-omitempty: true x-omitempty: true
name: name:
type: string type: string
purchasePrice:
example: "0"
type: string
quantity: quantity:
type: integer type: integer
updatedAt: updatedAt:

View file

@ -97,18 +97,18 @@ func newCsvRow(row []string) csvRow {
LabelStr: row[2], LabelStr: row[2],
Item: repo.ItemOut{ Item: repo.ItemOut{
ItemSummary: repo.ItemSummary{ ItemSummary: repo.ItemSummary{
ImportRef: row[0], ImportRef: row[0],
Quantity: parseInt(row[3]), Quantity: parseInt(row[3]),
Name: row[4], Name: row[4],
Description: row[5], Description: row[5],
Insured: parseBool(row[6]), Insured: parseBool(row[6]),
PurchasePrice: parseFloat(row[12]),
}, },
SerialNumber: row[7], SerialNumber: row[7],
ModelNumber: row[8], ModelNumber: row[8],
Manufacturer: row[9], Manufacturer: row[9],
Notes: row[10], Notes: row[10],
PurchaseFrom: row[11], PurchaseFrom: row[11],
PurchasePrice: parseFloat(row[12]),
PurchaseTime: parseDate(row[13]), PurchaseTime: parseDate(row[13]),
LifetimeWarranty: parseBool(row[14]), LifetimeWarranty: parseBool(row[14]),
WarrantyExpires: parseDate(row[15]), WarrantyExpires: parseDate(row[15]),

View file

@ -101,6 +101,8 @@ type (
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
PurchasePrice float64 `json:"purchasePrice,string"`
// Edges // Edges
Location *LocationSummary `json:"location,omitempty" extensions:"x-nullable,x-omitempty"` Location *LocationSummary `json:"location,omitempty" extensions:"x-nullable,x-omitempty"`
Labels []LabelSummary `json:"labels"` Labels []LabelSummary `json:"labels"`
@ -121,9 +123,8 @@ type (
WarrantyDetails string `json:"warrantyDetails"` WarrantyDetails string `json:"warrantyDetails"`
// Purchase // Purchase
PurchaseTime time.Time `json:"purchaseTime"` PurchaseTime time.Time `json:"purchaseTime"`
PurchaseFrom string `json:"purchaseFrom"` PurchaseFrom string `json:"purchaseFrom"`
PurchasePrice float64 `json:"purchasePrice,string"`
// Sold // Sold
SoldTime time.Time `json:"soldTime"` SoldTime time.Time `json:"soldTime"`
@ -157,13 +158,14 @@ func mapItemSummary(item *ent.Item) ItemSummary {
} }
return ItemSummary{ return ItemSummary{
ID: item.ID, ID: item.ID,
Name: item.Name, Name: item.Name,
Description: item.Description, Description: item.Description,
Quantity: item.Quantity, Quantity: item.Quantity,
CreatedAt: item.CreatedAt, CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt, UpdatedAt: item.UpdatedAt,
Archived: item.Archived, Archived: item.Archived,
PurchasePrice: item.PurchasePrice,
// Edges // Edges
Location: location, Location: location,
@ -230,9 +232,8 @@ func mapItemOut(item *ent.Item) ItemOut {
Manufacturer: item.Manufacturer, Manufacturer: item.Manufacturer,
// Purchase // Purchase
PurchaseTime: item.PurchaseTime, PurchaseTime: item.PurchaseTime,
PurchaseFrom: item.PurchaseFrom, PurchaseFrom: item.PurchaseFrom,
PurchasePrice: item.PurchasePrice,
// Sold // Sold
SoldTime: item.SoldTime, SoldTime: item.SoldTime,

1
frontend/.nuxtignore Normal file
View file

@ -0,0 +1 @@
pages/**/*.ts

View file

@ -1,6 +1,6 @@
<template> <template>
<NuxtLayout> <NuxtLayout>
<Html lang="en" :data-theme="theme" /> <Html lang="en" :data-theme="theme || 'homebox'" />
<NuxtPage /> <NuxtPage />
</NuxtLayout> </NuxtLayout>
</template> </template>

View file

@ -0,0 +1,3 @@
.text-no-transform {
text-transform: none !important;
}

View file

@ -0,0 +1,494 @@
<script lang="ts" setup>
// https://stackoverflow.com/questions/36721830/convert-hsl-to-rgb-and-hex
function hslToHex(h: number, s: number, l: number) {
l /= 100;
const a = (s * Math.min(l, 1 - l)) / 100;
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color)
.toString(16)
.padStart(2, "0"); // convert to Hex and prefix "0" if needed
};
return `#${f(0)}${f(8)}${f(4)}`;
}
function unstring(value: string): [number, number, number] {
const [h, s, l] = value
.replace("hsla(", "")
.replace(")", "")
.replace(/%/g, "")
.split(",")
.map(v => v.trim());
return [Number(h), Number(s), Number(l)];
}
const primary = useCssVar("--p");
const primaryHex = computed(() => hslToHex(...unstring(primary.value)));
const secondary = useCssVar("--s");
const secondaryHex = computed(() => hslToHex(...unstring(secondary.value)));
const accent = useCssVar("--a");
const accentHex = computed(() => hslToHex(...unstring(accent.value)));
const neutral = useCssVar("--n");
const neutralHex = computed(() => hslToHex(...unstring(neutral.value)));
const base100 = useCssVar("--b1");
const base100Hex = computed(() => hslToHex(...unstring(base100.value)));
</script>
<template>
<svg viewBox="0 0 1440 237" role="img" fill="none" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_5_1510)" filter="url(#filter0_d_5_1510)">
<rect width="1440" height="310" transform="translate(0 -103)" fill="white" />
<rect y="-103" width="1440" height="310" :fill="neutralHex" />
<g clip-path="url(#clip1_5_1510)">
<path
d="M1344.93 230.569H1269.12V185.083H1299.44C1305.42 185.083 1311.33 186.26 1316.85 188.546C1322.37 190.832 1327.38 194.182 1331.61 198.406C1340.14 206.936 1344.93 218.505 1344.93 230.569V230.569Z"
:fill="primaryHex"
/>
<path
d="M1297.89 170.07C1297.89 166.675 1295.14 163.923 1291.75 163.923C1288.35 163.923 1285.6 166.675 1285.6 170.07C1285.6 173.465 1288.35 176.218 1291.75 176.218C1295.14 176.218 1297.89 173.465 1297.89 170.07Z"
:fill="base100Hex"
/>
<path
d="M1328.45 170.07C1328.45 166.675 1325.7 163.923 1322.3 163.923C1318.91 163.923 1316.15 166.675 1316.15 170.07C1316.15 173.465 1318.91 176.218 1322.3 176.218C1325.7 176.218 1328.45 173.465 1328.45 170.07Z"
:fill="base100Hex"
/>
</g>
<g clip-path="url(#clip2_5_1510)">
<path
d="M1401.78 60C1409.28 60 1416.61 62.2231 1422.84 66.388C1429.08 70.553 1433.93 76.4728 1436.8 83.3989C1439.67 90.325 1440.42 97.9463 1438.96 105.299C1437.5 112.652 1433.89 119.406 1428.59 124.707C1423.29 130.008 1416.53 133.618 1409.18 135.08C1401.83 136.543 1394.2 135.792 1387.28 132.923C1380.35 130.054 1374.43 125.196 1370.27 118.963C1366.1 112.729 1363.88 105.401 1363.88 97.9043V60H1401.78Z"
:fill="base100Hex"
/>
</g>
<g clip-path="url(#clip3_5_1510)">
<path d="M1269.12 135.809H1344.93V60H1269.12V135.809Z" :fill="accentHex" />
</g>
<g clip-path="url(#clip4_5_1510)">
<path
d="M1250.17 97.9043C1250.17 105.401 1247.95 112.729 1243.78 118.963C1239.62 125.196 1233.7 130.054 1226.77 132.923C1219.84 135.792 1212.22 136.543 1204.87 135.08C1197.52 133.618 1190.76 130.008 1185.46 124.707C1180.16 119.406 1176.55 112.652 1175.09 105.299C1173.63 97.9463 1174.38 90.325 1177.25 83.3989C1180.11 76.4728 1184.97 70.553 1191.21 66.388C1197.44 62.2231 1204.77 60 1212.26 60H1250.17V97.9043Z"
:fill="secondaryHex"
/>
</g>
<g clip-path="url(#clip5_5_1510)">
<path d="M1079.6 135.809H1155.41V60H1079.6V135.809Z" :fill="accentHex" />
</g>
<g clip-path="url(#clip6_5_1510)">
<path
d="M890.08 60L965.888 60V105.485H935.565C929.592 105.485 923.677 104.309 918.159 102.023C912.64 99.7369 907.626 96.3865 903.402 92.1628C894.872 83.6327 890.08 72.0634 890.08 60V60Z"
:fill="primaryHex"
/>
<path
d="M937.114 120.498C937.114 123.893 939.866 126.646 943.261 126.646C946.656 126.646 949.408 123.893 949.408 120.498C949.408 117.103 946.656 114.351 943.261 114.351C939.866 114.351 937.114 117.103 937.114 120.498Z"
:fill="base100Hex"
/>
<path
d="M906.56 120.498C906.56 123.893 909.312 126.646 912.707 126.646C916.102 126.646 918.855 123.893 918.855 120.498C918.855 117.103 916.102 114.351 912.707 114.351C909.312 114.351 906.56 117.103 906.56 120.498Z"
:fill="base100Hex"
/>
</g>
<g clip-path="url(#clip7_5_1510)">
<path d="M795.32 154.76V230.569H871.128V154.76H795.32Z" :fill="accentHex" />
</g>
<g clip-path="url(#clip8_5_1510)">
<path
d="M776.368 154.76V230.569H730.883V200.245C730.883 194.272 732.06 188.357 734.346 182.839C736.632 177.32 739.982 172.306 744.206 168.082C752.736 159.552 764.305 154.76 776.368 154.76V154.76Z"
:fill="primaryHex"
/>
<path
d="M715.87 201.794C712.475 201.794 709.723 204.546 709.723 207.941C709.723 211.336 712.475 214.089 715.87 214.089C719.265 214.089 722.018 211.336 722.018 207.941C722.018 204.546 719.265 201.794 715.87 201.794Z"
:fill="base100Hex"
/>
<path
d="M715.87 171.24C712.475 171.24 709.723 173.992 709.723 177.387C709.723 180.782 712.475 183.535 715.87 183.535C719.265 183.535 722.018 180.782 722.018 177.387C722.018 173.992 719.265 171.24 715.87 171.24Z"
:fill="base100Hex"
/>
</g>
<g clip-path="url(#clip9_5_1510)">
<path
d="M871.128 97.9043C871.128 105.401 868.905 112.729 864.74 118.963C860.575 125.196 854.656 130.054 847.73 132.923C840.803 135.792 833.182 136.543 825.829 135.08C818.477 133.618 811.723 130.008 806.422 124.707C801.121 119.406 797.511 112.652 796.048 105.299C794.586 97.9463 795.336 90.325 798.205 83.3989C801.074 76.4728 805.932 70.553 812.166 66.388C818.399 62.2231 825.727 60 833.224 60H871.128V97.9043Z"
:fill="base100Hex"
/>
</g>
<g clip-path="url(#clip10_5_1510)">
<path
d="M679.334 184.193C670.427 184.193 670.427 201.136 661.519 201.136C652.611 201.136 652.615 184.193 643.712 184.193C634.808 184.193 634.802 201.136 625.892 201.136C616.982 201.136 616.984 184.193 608.074 184.193"
stroke="#F6FAFB"
stroke-width="4.54851"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<g clip-path="url(#clip11_5_1510)">
<path
d="M605.8 97.9043C605.8 90.4075 608.023 83.0791 612.188 76.8458C616.353 70.6125 622.273 65.7542 629.199 62.8853C636.125 60.0164 643.746 59.2658 651.099 60.7283C658.452 62.1909 665.206 65.8009 670.507 71.1019C675.808 76.4029 679.418 83.1568 680.88 90.5095C682.343 97.8622 681.592 105.483 678.723 112.41C675.854 119.336 670.996 125.256 664.763 129.42C658.529 133.585 651.201 135.809 643.704 135.809H605.8V97.9043Z"
:fill="base100Hex"
/>
</g>
<g clip-path="url(#clip12_5_1510)">
<path
d="M511.04 135.809V60L556.525 60V90.3234C556.525 96.2966 555.349 102.211 553.063 107.73C550.777 113.248 547.426 118.263 543.203 122.486C534.673 131.016 523.103 135.809 511.04 135.809V135.809Z"
:fill="primaryHex"
/>
<path
d="M571.538 88.7746C574.933 88.7746 577.685 86.0224 577.685 82.6273C577.685 79.2323 574.933 76.48 571.538 76.48C568.143 76.48 565.391 79.2323 565.391 82.6273C565.391 86.0224 568.143 88.7746 571.538 88.7746Z"
:fill="base100Hex"
/>
<path
d="M571.538 119.328C574.933 119.328 577.685 116.576 577.685 113.181C577.685 109.786 574.933 107.034 571.538 107.034C568.143 107.034 565.391 109.786 565.391 113.181C565.391 116.576 568.143 119.328 571.538 119.328Z"
:fill="base100Hex"
/>
</g>
<g clip-path="url(#clip13_5_1510)">
<path
d="M489.814 184.193C480.907 184.193 480.907 201.136 471.999 201.136C463.091 201.136 463.095 184.193 454.192 184.193C445.288 184.193 445.282 201.136 436.372 201.136C427.462 201.136 427.464 184.193 418.554 184.193"
stroke="#F6FAFB"
stroke-width="4.54851"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<g clip-path="url(#clip14_5_1510)">
<path
d="M454.184 135.809C446.687 135.809 439.359 133.585 433.126 129.42C426.892 125.256 422.034 119.336 419.165 112.41C416.296 105.483 415.546 97.8622 417.008 90.5095C418.471 83.1568 422.081 76.4029 427.382 71.1019C432.683 65.8009 439.437 62.1909 446.789 60.7283C454.142 59.2658 461.763 60.0164 468.689 62.8853C475.616 65.7542 481.535 70.6125 485.7 76.8458C489.865 83.0791 492.088 90.4075 492.088 97.9043V135.809H454.184Z"
:fill="secondaryHex"
/>
</g>
<g clip-path="url(#clip15_5_1510)">
<path
d="M132 230.569V154.76H177.485V185.083C177.485 191.057 176.309 196.971 174.023 202.49C171.737 208.008 168.387 213.023 164.163 217.246C155.633 225.776 144.063 230.569 132 230.569V230.569Z"
:fill="primaryHex"
/>
<path
d="M192.498 183.535C195.893 183.535 198.646 180.782 198.646 177.387C198.646 173.992 195.893 171.24 192.498 171.24C189.103 171.24 186.351 173.992 186.351 177.387C186.351 180.782 189.103 183.535 192.498 183.535Z"
:fill="base100Hex"
/>
<path
d="M192.498 214.089C195.893 214.089 198.646 211.336 198.646 207.941C198.646 204.546 195.893 201.794 192.498 201.794C189.103 201.794 186.351 204.546 186.351 207.941C186.351 211.336 189.103 214.089 192.498 214.089Z"
:fill="base100Hex"
/>
</g>
<g clip-path="url(#clip16_5_1510)">
<path
d="M302.569 135.809H226.76V90.3234H257.083C263.057 90.3234 268.971 91.4999 274.49 93.7858C280.008 96.0716 285.023 99.422 289.246 103.646C297.776 112.176 302.569 123.745 302.569 135.809V135.809Z"
:fill="primaryHex"
/>
<path
d="M255.535 75.3103C255.535 71.9152 252.782 69.163 249.387 69.163C245.992 69.163 243.24 71.9152 243.24 75.3103C243.24 78.7054 245.992 81.4576 249.387 81.4576C252.782 81.4576 255.535 78.7054 255.535 75.3103Z"
:fill="base100Hex"
/>
<path
d="M286.089 75.3103C286.089 71.9152 283.336 69.163 279.941 69.163C276.546 69.163 273.794 71.9152 273.794 75.3103C273.794 78.7054 276.546 81.4576 279.941 81.4576C283.336 81.4576 286.089 78.7054 286.089 75.3103Z"
:fill="base100Hex"
/>
</g>
<g clip-path="url(#clip17_5_1510)">
<path d="M207.809 60L132 60V135.809H207.809V60Z" :fill="accentHex" />
</g>
<g clip-path="url(#clip18_5_1510)">
<path d="M502 -31V44.8085L577.809 44.8085V-31L502 -31Z" :fill="accentHex" />
</g>
<g clip-path="url(#clip19_5_1510)">
<path
d="M65.9043 -31.24C73.401 -31.24 80.7294 -29.017 86.9627 -24.852C93.1961 -20.687 98.0543 -14.7672 100.923 -7.84108C103.792 -0.914986 104.543 6.7063 103.08 14.059C101.618 21.4117 98.0076 28.1656 92.7066 33.4666C87.4056 38.7676 80.6517 42.3776 73.299 43.8402C65.9463 45.3027 58.325 44.5521 51.3989 41.6832C44.4728 38.8143 38.553 33.956 34.388 27.7227C30.2231 21.4894 28 14.161 28 6.66425L28 -31.24L65.9043 -31.24Z"
:fill="secondaryHex"
/>
</g>
<g clip-path="url(#clip20_5_1510)">
<path
d="M160.664 44.5685C153.168 44.5685 145.839 42.3455 139.606 38.1805C133.372 34.0155 128.514 28.0957 125.645 21.1696C122.776 14.2435 122.026 6.62223 123.488 -0.730478C124.951 -8.08318 128.561 -14.8371 133.862 -20.1381C139.163 -25.4391 145.917 -29.0491 153.27 -30.5117C160.622 -31.9742 168.244 -31.2236 175.17 -28.3547C182.096 -25.4858 188.016 -20.6275 192.181 -14.3942C196.345 -8.16088 198.569 -0.832478 198.569 6.66428V44.5685H160.664Z"
:fill="secondaryHex"
/>
</g>
<g clip-path="url(#clip21_5_1510)">
<path
d="M388.089 6.66425C388.089 14.161 385.865 21.4894 381.701 27.7227C377.536 33.956 371.616 38.8143 364.69 41.6832C357.764 44.5521 350.142 45.3027 342.79 43.8402C335.437 42.3776 328.683 38.7676 323.382 33.4666C318.081 28.1656 314.471 21.4117 313.008 14.059C311.546 6.7063 312.296 -0.914986 315.165 -7.84108C318.034 -14.7672 322.893 -20.687 329.126 -24.852C335.359 -29.017 342.688 -31.24 350.184 -31.24L388.089 -31.24V6.66425Z"
:fill="base100Hex"
/>
</g>
<g clip-path="url(#clip22_5_1510)">
<path
d="M444.944 -31.24C452.441 -31.24 459.769 -29.017 466.003 -24.852C472.236 -20.687 477.094 -14.7672 479.963 -7.84108C482.832 -0.914986 483.583 6.7063 482.12 14.059C480.658 21.4117 477.048 28.1656 471.747 33.4666C466.446 38.7676 459.692 42.3776 452.339 43.8402C444.986 45.3027 437.365 44.5521 430.439 41.6832C423.513 38.8143 417.593 33.956 413.428 27.7227C409.263 21.4894 407.04 14.161 407.04 6.66425V-31.24L444.944 -31.24Z"
:fill="secondaryHex"
/>
</g>
<g clip-path="url(#clip23_5_1510)">
<path
d="M634.464 44.5685C626.968 44.5685 619.639 42.3455 613.406 38.1805C607.172 34.0155 602.314 28.0957 599.445 21.1696C596.576 14.2435 595.826 6.62223 597.288 -0.730478C598.751 -8.08318 602.361 -14.8371 607.662 -20.1381C612.963 -25.4391 619.717 -29.0491 627.07 -30.5117C634.422 -31.9742 642.043 -31.2236 648.97 -28.3547C655.896 -25.4858 661.816 -20.6275 665.98 -14.3942C670.145 -8.16088 672.369 -0.832478 672.369 6.66428V44.5685H634.464Z"
:fill="secondaryHex"
/>
</g>
<g clip-path="url(#clip24_5_1510)">
<path
d="M823.984 -31.24C831.481 -31.24 838.809 -29.017 845.043 -24.852C851.276 -20.687 856.134 -14.7672 859.003 -7.84108C861.872 -0.914986 862.623 6.7063 861.16 14.059C859.698 21.4117 856.088 28.1656 850.787 33.4666C845.486 38.7676 838.732 42.3776 831.379 43.8402C824.026 45.3027 816.405 44.5521 809.479 41.6832C802.553 38.8143 796.633 33.956 792.468 27.7227C788.303 21.4894 786.08 14.161 786.08 6.66425V-31.24L823.984 -31.24Z"
:fill="secondaryHex"
/>
</g>
<g clip-path="url(#clip25_5_1510)">
<path
d="M956.648 -31.24V44.5685L911.163 44.5685V14.2451C911.163 8.27192 912.34 2.35722 914.626 -3.16129C916.912 -8.67979 920.262 -13.694 924.486 -17.9177C933.016 -26.4478 944.585 -31.24 956.648 -31.24V-31.24Z"
:fill="primaryHex"
/>
<path
d="M896.15 15.7939C892.755 15.7939 890.003 18.5461 890.003 21.9412C890.003 25.3363 892.755 28.0885 896.15 28.0885C899.545 28.0885 902.298 25.3363 902.298 21.9412C902.298 18.5461 899.545 15.7939 896.15 15.7939Z"
:fill="secondaryHex"
/>
</g>
<g clip-path="url(#clip26_5_1510)">
<path
d="M1013.5 -31.24C1021 -31.24 1028.33 -29.017 1034.56 -24.852C1040.8 -20.687 1045.65 -14.7672 1048.52 -7.84108C1051.39 -0.914986 1052.14 6.7063 1050.68 14.059C1049.22 21.4117 1045.61 28.1656 1040.31 33.4666C1035.01 38.7676 1028.25 42.3776 1020.9 43.8402C1013.55 45.3027 1005.93 44.5521 998.999 41.6832C992.073 38.8143 986.153 33.956 981.988 27.7227C977.823 21.4894 975.6 14.161 975.6 6.66425V-31.24L1013.5 -31.24Z"
:fill="secondaryHex"
/>
</g>
<g clip-path="url(#clip27_5_1510)">
<path
d="M1070.36 44.5685V-31.24H1115.85V-0.916588C1115.85 5.0566 1114.67 10.9713 1112.38 16.4898C1110.1 22.0083 1106.75 27.0226 1102.52 31.2462C1093.99 39.7764 1082.42 44.5685 1070.36 44.5685V44.5685Z"
:fill="primaryHex"
/>
<path
d="M1130.86 28.0885C1134.25 28.0885 1137.01 25.3363 1137.01 21.9412C1137.01 18.5461 1134.25 15.7939 1130.86 15.7939C1127.46 15.7939 1124.71 18.5461 1124.71 21.9412C1124.71 25.3363 1127.46 28.0885 1130.86 28.0885Z"
:fill="secondaryHex"
/>
</g>
<g clip-path="url(#clip28_5_1510)">
<path
d="M1203.02 44.5685C1195.53 44.5685 1188.2 42.3455 1181.97 38.1805C1175.73 34.0155 1170.87 28.0957 1168.01 21.1696C1165.14 14.2435 1164.39 6.62223 1165.85 -0.730478C1167.31 -8.08318 1170.92 -14.8371 1176.22 -20.1381C1181.52 -25.4391 1188.28 -29.0491 1195.63 -30.5117C1202.98 -31.9742 1210.6 -31.2236 1217.53 -28.3547C1224.46 -25.4858 1230.38 -20.6275 1234.54 -14.3942C1238.71 -8.16088 1240.93 -0.832478 1240.93 6.66428V44.5685H1203.02Z"
:fill="base100Hex"
/>
</g>
<g clip-path="url(#clip29_5_1510)">
<path
d="M1335.69 -31.24V44.5685L1290.2 44.5685V14.2451C1290.2 8.27192 1291.38 2.35722 1293.67 -3.16129C1295.95 -8.67979 1299.3 -13.694 1303.53 -17.9177C1312.06 -26.4478 1323.63 -31.24 1335.69 -31.24V-31.24Z"
:fill="primaryHex"
/>
<path
d="M1275.19 15.7939C1271.8 15.7939 1269.04 18.5461 1269.04 21.9412C1269.04 25.3363 1271.8 28.0885 1275.19 28.0885C1278.59 28.0885 1281.34 25.3363 1281.34 21.9412C1281.34 18.5461 1278.59 15.7939 1275.19 15.7939Z"
:fill="secondaryHex"
/>
</g>
<g clip-path="url(#clip30_5_1510)">
<path
d="M292.808 -31V44.8085L247.323 44.8085V14.4851C247.323 8.51194 248.5 2.59724 250.786 -2.92126C253.072 -8.43977 256.422 -13.454 260.646 -17.6777C269.176 -26.2078 280.745 -31 292.808 -31V-31Z"
:fill="primaryHex"
/>
<path
d="M232.31 16.0339C228.915 16.0339 226.163 18.7861 226.163 22.1812C226.163 25.5763 228.915 28.3285 232.31 28.3285C235.705 28.3285 238.458 25.5763 238.458 22.1812C238.458 18.7861 235.705 16.0339 232.31 16.0339Z"
:fill="secondaryHex"
/>
</g>
<path
d="M937.07 193V152.443H946.872V168.721H962.854V152.443H972.636V193H962.854V176.702H946.872V193H937.07ZM992.984 193.574C989.789 193.574 987.043 192.921 984.746 191.614C982.462 190.294 980.7 188.458 979.459 186.108C978.231 183.745 977.617 181.006 977.617 177.89C977.617 174.761 978.231 172.022 979.459 169.672C980.7 167.308 982.462 165.473 984.746 164.166C987.043 162.846 989.789 162.186 992.984 162.186C996.179 162.186 998.919 162.846 1001.2 164.166C1003.5 165.473 1005.26 167.308 1006.49 169.672C1007.73 172.022 1008.35 174.761 1008.35 177.89C1008.35 181.006 1007.73 183.745 1006.49 186.108C1005.26 188.458 1003.5 190.294 1001.2 191.614C998.919 192.921 996.179 193.574 992.984 193.574ZM993.044 186.267C994.206 186.267 995.189 185.91 995.994 185.197C996.8 184.485 997.414 183.494 997.836 182.227C998.272 180.96 998.49 179.494 998.49 177.831C998.49 176.141 998.272 174.662 997.836 173.395C997.414 172.127 996.8 171.137 995.994 170.424C995.189 169.711 994.206 169.355 993.044 169.355C991.842 169.355 990.826 169.711 989.994 170.424C989.175 171.137 988.548 172.127 988.113 173.395C987.69 174.662 987.479 176.141 987.479 177.831C987.479 179.494 987.69 180.96 988.113 182.227C988.548 183.494 989.175 184.485 989.994 185.197C990.826 185.91 991.842 186.267 993.044 186.267ZM1013.31 193V162.582H1022.52V168.167H1022.86C1023.49 166.318 1024.56 164.859 1026.07 163.79C1027.57 162.721 1029.37 162.186 1031.45 162.186C1033.56 162.186 1035.37 162.727 1036.88 163.81C1038.38 164.892 1039.34 166.345 1039.75 168.167H1040.07C1040.63 166.358 1041.74 164.912 1043.39 163.83C1045.04 162.734 1046.99 162.186 1049.24 162.186C1052.11 162.186 1054.45 163.11 1056.25 164.958C1058.04 166.794 1058.94 169.315 1058.94 172.523V193H1049.26V174.741C1049.26 173.223 1048.87 172.068 1048.09 171.276C1047.31 170.47 1046.3 170.068 1045.06 170.068C1043.72 170.068 1042.67 170.503 1041.91 171.375C1041.16 172.233 1040.78 173.388 1040.78 174.84V193H1031.47V174.642C1031.47 173.23 1031.09 172.114 1030.32 171.296C1029.56 170.477 1028.55 170.068 1027.29 170.068C1026.45 170.068 1025.7 170.272 1025.06 170.682C1024.41 171.078 1023.9 171.645 1023.53 172.385C1023.17 173.124 1023 173.995 1023 174.999V193H1013.31ZM1079.16 193.574C1075.98 193.574 1073.23 192.947 1070.92 191.693C1068.62 190.426 1066.85 188.623 1065.61 186.287C1064.39 183.937 1063.77 181.144 1063.77 177.91C1063.77 174.768 1064.39 172.022 1065.63 169.672C1066.87 167.308 1068.62 165.473 1070.88 164.166C1073.14 162.846 1075.8 162.186 1078.86 162.186C1081.03 162.186 1083.01 162.523 1084.8 163.196C1086.6 163.869 1088.15 164.866 1089.46 166.186C1090.76 167.506 1091.78 169.137 1092.51 171.078C1093.23 173.005 1093.6 175.217 1093.6 177.712V180.128H1067.16V174.504H1084.58C1084.57 173.474 1084.33 172.556 1083.85 171.751C1083.38 170.946 1082.72 170.319 1081.89 169.87C1081.07 169.408 1080.13 169.177 1079.06 169.177C1077.98 169.177 1077.01 169.421 1076.15 169.909C1075.29 170.385 1074.61 171.038 1074.11 171.87C1073.61 172.688 1073.34 173.619 1073.32 174.662V180.385C1073.32 181.626 1073.56 182.715 1074.05 183.653C1074.54 184.577 1075.23 185.296 1076.13 185.811C1077.03 186.326 1078.1 186.584 1079.34 186.584C1080.2 186.584 1080.97 186.465 1081.67 186.227C1082.37 185.99 1082.97 185.64 1083.48 185.178C1083.98 184.716 1084.35 184.148 1084.6 183.475L1093.5 183.732C1093.13 185.726 1092.31 187.462 1091.06 188.94C1089.82 190.406 1088.19 191.548 1086.17 192.366C1084.15 193.172 1081.81 193.574 1079.16 193.574ZM1098.54 193V152.443H1115.45C1118.49 152.443 1121.03 152.872 1123.08 153.73C1125.14 154.588 1126.68 155.789 1127.71 157.334C1128.75 158.879 1129.28 160.668 1129.28 162.701C1129.28 164.246 1128.95 165.625 1128.31 166.84C1127.66 168.041 1126.77 169.038 1125.63 169.83C1124.5 170.622 1123.18 171.177 1121.69 171.494V171.89C1123.33 171.969 1124.84 172.411 1126.23 173.216C1127.63 174.022 1128.75 175.144 1129.59 176.583C1130.44 178.009 1130.86 179.699 1130.86 181.653C1130.86 183.831 1130.31 185.778 1129.2 187.495C1128.09 189.198 1126.48 190.544 1124.38 191.535C1122.29 192.512 1119.74 193 1116.74 193H1098.54ZM1108.34 185.098H1114.4C1116.53 185.098 1118.09 184.696 1119.1 183.89C1120.11 183.085 1120.62 181.963 1120.62 180.524C1120.62 179.481 1120.38 178.583 1119.89 177.831C1119.4 177.065 1118.71 176.477 1117.81 176.068C1116.91 175.646 1115.84 175.434 1114.58 175.434H1108.34V185.098ZM1108.34 169.117H1113.77C1114.84 169.117 1115.79 168.939 1116.62 168.582C1117.45 168.226 1118.1 167.711 1118.56 167.038C1119.04 166.364 1119.28 165.553 1119.28 164.602C1119.28 163.242 1118.79 162.173 1117.83 161.394C1116.87 160.615 1115.57 160.225 1113.93 160.225H1108.34V169.117ZM1179.75 162.582L1184.77 172.603L1189.95 162.582H1199.72L1191.22 177.791L1200.03 193H1190.35L1184.77 182.9L1179.32 193H1169.5L1178.33 177.791L1169.93 162.582H1179.75Z"
fill="white"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1169.06 166.844C1170.19 168 1171 169.341 1171.67 170.753C1171.89 171.21 1171.84 171.75 1171.54 172.159C1171.24 172.569 1170.75 172.782 1170.24 172.715C1170.24 172.716 1170.24 172.716 1170.24 172.716"
fill="#808080"
/>
<path
d="M1169.06 166.844C1170.19 168 1171 169.341 1171.67 170.753C1171.89 171.21 1171.84 171.75 1171.54 172.159C1171.24 172.569 1170.75 172.782 1170.24 172.715C1170.24 172.716 1170.24 172.716 1170.24 172.716"
stroke="black"
stroke-width="0.95462"
stroke-miterlimit="5.42683"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1150.98 157.253C1150.39 157.281 1149.8 157.441 1149.27 157.732C1145.73 159.648 1138.18 163.733 1134.5 165.725C1133.86 166.07 1133.34 166.574 1132.98 167.173L1151.31 176.989C1151.31 176.989 1150.15 178.915 1149.13 180.628C1148.25 182.092 1146.39 182.616 1144.88 181.822L1132.39 175.258V185.505C1132.39 186.552 1132.99 187.518 1133.96 188.042C1137.49 189.956 1146.08 194.603 1149.61 196.517C1150.58 197.04 1151.77 197.04 1152.74 196.517C1156.28 194.603 1164.86 189.956 1168.4 188.042C1169.36 187.518 1169.96 186.552 1169.96 185.505V169.256C1169.96 167.782 1169.15 166.427 1167.86 165.725C1164.18 163.733 1156.63 159.648 1153.09 157.732C1152.43 157.377 1151.7 157.217 1150.98 157.253"
fill="#DADADA"
/>
<path
d="M1150.98 157.253C1150.39 157.281 1149.8 157.441 1149.27 157.732C1145.73 159.648 1138.18 163.733 1134.5 165.725C1133.86 166.07 1133.34 166.574 1132.98 167.173L1151.31 176.989C1151.31 176.989 1150.15 178.915 1149.13 180.628C1148.25 182.092 1146.39 182.616 1144.88 181.822L1132.39 175.258V185.505C1132.39 186.552 1132.99 187.518 1133.96 188.042C1137.49 189.956 1146.08 194.603 1149.61 196.517C1150.58 197.04 1151.77 197.04 1152.74 196.517C1156.28 194.603 1164.86 189.956 1168.4 188.042C1169.36 187.518 1169.96 186.552 1169.96 185.505V169.256C1169.96 167.782 1169.15 166.427 1167.86 165.725C1164.18 163.733 1156.63 159.648 1153.09 157.732C1152.43 157.377 1151.7 157.217 1150.98 157.253"
stroke="black"
stroke-width="0.954666"
stroke-miterlimit="5.42683"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1151.31 176.989V157.334C1150.71 157.363 1149.8 157.441 1149.27 157.732C1145.73 159.648 1138.18 163.733 1134.5 165.725C1133.86 166.07 1133.34 166.574 1132.98 167.173L1151.31 176.989L1151.31 176.989Z"
fill="#808080"
stroke="black"
stroke-width="0.954666"
stroke-miterlimit="5.42683"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1132.75 167.607L1131.02 170.917C1130.2 172.486 1130.81 174.423 1132.37 175.247L1132.39 175.258M1132.39 175.258L1144.88 181.822C1146.39 182.616 1148.25 182.092 1149.13 180.628C1150.15 178.915 1151.31 176.989 1151.31 176.989L1132.98 167.173L1132.97 167.189L1132.75 167.607"
fill="#DADADA"
/>
<path
d="M1132.75 167.607L1131.02 170.917C1130.2 172.486 1130.81 174.423 1132.37 175.247L1132.39 175.258M1132.39 175.258L1144.88 181.822C1146.39 182.616 1148.25 182.092 1149.13 180.628C1150.15 178.915 1151.31 176.989 1151.31 176.989L1132.98 167.173L1132.97 167.189L1132.75 167.607"
stroke="black"
stroke-width="0.954666"
stroke-miterlimit="5.42683"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M1151.18 157.409V176.601"
stroke="black"
stroke-width="0.709918"
stroke-miterlimit="5.42683"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1167.4 173.975C1167.4 173.782 1167.3 173.604 1167.13 173.508C1166.96 173.411 1166.76 173.411 1166.59 173.508L1155.31 180.023C1155.14 180.119 1155.04 180.297 1155.04 180.49V187.642C1155.04 187.835 1155.14 188.013 1155.31 188.109C1155.47 188.206 1155.68 188.206 1155.85 188.109L1167.13 181.594C1167.3 181.498 1167.4 181.32 1167.4 181.127V173.975V173.975Z"
:fill="primaryHex"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1167.4 173.975C1167.4 173.782 1167.3 173.604 1167.13 173.508C1166.96 173.411 1166.76 173.411 1166.59 173.508L1155.31 180.023C1155.14 180.119 1155.04 180.297 1155.04 180.49V187.642C1155.04 187.835 1155.14 188.013 1155.31 188.109C1155.47 188.206 1155.68 188.206 1155.85 188.109L1167.13 181.594C1167.3 181.498 1167.4 181.32 1167.4 181.127V173.975V173.975ZM1155.9 180.674V187.089L1166.54 180.943V174.528L1155.9 180.674Z"
fill="black"
/>
<path
d="M1151.18 196.817V177.03"
stroke="black"
stroke-width="0.954758"
stroke-miterlimit="5.42683"
stroke-linecap="round"
/>
<path
d="M1151.31 177.078L1169.02 166.958"
stroke="black"
stroke-width="0.954712"
stroke-miterlimit="5.42683"
stroke-linecap="round"
/>
</g>
<defs>
<filter
id="filter0_d_5_1510"
x="-22"
y="-117"
width="1484"
height="354"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect1_dropShadow_5_1510" />
<feOffset dy="8" />
<feGaussianBlur stdDeviation="13" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix type="matrix" values="0 0 0 0 0.146667 0 0 0 0 0.183333 0 0 0 0 0.157752 0 0 0 0.25 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_5_1510" />
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_5_1510" result="shape" />
</filter>
<clipPath id="clip0_5_1510">
<rect width="1440" height="310" fill="white" transform="translate(0 -103)" />
</clipPath>
<clipPath id="clip1_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(1269.12 230.569) rotate(-90)" />
</clipPath>
<clipPath id="clip2_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(1439.69 135.809) rotate(-180)" />
</clipPath>
<clipPath id="clip3_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(1344.93 135.809) rotate(-180)" />
</clipPath>
<clipPath id="clip4_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(1174.36 135.809) rotate(-90)" />
</clipPath>
<clipPath id="clip5_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(1155.41 135.809) rotate(-180)" />
</clipPath>
<clipPath id="clip6_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(965.888 60) rotate(90)" />
</clipPath>
<clipPath id="clip7_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(795.32 230.569) rotate(-90)" />
</clipPath>
<clipPath id="clip8_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(776.368 230.569) rotate(-180)" />
</clipPath>
<clipPath id="clip9_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(795.32 135.809) rotate(-90)" />
</clipPath>
<clipPath id="clip10_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(681.608 154.76) rotate(90)" />
</clipPath>
<clipPath id="clip11_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(681.608 60) rotate(90)" />
</clipPath>
<clipPath id="clip12_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(511.04 60)" />
</clipPath>
<clipPath id="clip13_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(492.088 154.76) rotate(90)" />
</clipPath>
<clipPath id="clip14_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(416.28 60)" />
</clipPath>
<clipPath id="clip15_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(132 154.76)" />
</clipPath>
<clipPath id="clip16_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(226.76 135.809) rotate(-90)" />
</clipPath>
<clipPath id="clip17_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(132 60)" />
</clipPath>
<clipPath id="clip18_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(502 44.8085) rotate(-90)" />
</clipPath>
<clipPath id="clip19_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(103.809 44.5685) rotate(180)" />
</clipPath>
<clipPath id="clip20_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(122.76 -31.24)" />
</clipPath>
<clipPath id="clip21_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(312.28 44.5685) rotate(-90)" />
</clipPath>
<clipPath id="clip22_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(482.849 44.5685) rotate(180)" />
</clipPath>
<clipPath id="clip23_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(596.56 -31.24)" />
</clipPath>
<clipPath id="clip24_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(861.889 44.5685) rotate(180)" />
</clipPath>
<clipPath id="clip25_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(956.648 44.5685) rotate(180)" />
</clipPath>
<clipPath id="clip26_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(1051.41 44.5685) rotate(180)" />
</clipPath>
<clipPath id="clip27_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(1070.36 -31.24)" />
</clipPath>
<clipPath id="clip28_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(1165.12 -31.24)" />
</clipPath>
<clipPath id="clip29_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(1335.69 44.5685) rotate(180)" />
</clipPath>
<clipPath id="clip30_5_1510">
<rect width="75.8085" height="75.8085" fill="white" transform="translate(292.808 44.8085) rotate(180)" />
</clipPath>
</defs>
</svg>
</template>

View file

@ -0,0 +1,90 @@
<template>
<BaseModal v-model="dialog">
<template #title> Import CSV File </template>
<p>
Import a CSV file containing your items, labels, and locations. See documentation for more information on the
required format.
</p>
<form @submit.prevent="submitCsvFile">
<div class="flex flex-col gap-2 py-6">
<input ref="importRef" type="file" class="hidden" accept=".csv,.tsv" @change="setFile" />
<BaseButton type="button" @click="uploadCsv">
<Icon class="h-5 w-5 mr-2" name="mdi-upload" />
Upload
</BaseButton>
<p class="text-center pt-4 -mb-5">
{{ importCsv?.name }}
</p>
</div>
<div class="modal-action">
<BaseButton type="submit" :disabled="!importCsv"> Submit </BaseButton>
</div>
</form>
</BaseModal>
</template>
<script setup lang="ts">
type Props = {
modelValue: boolean;
};
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
});
const emit = defineEmits(["update:modelValue"]);
const dialog = useVModel(props, "modelValue", emit);
const api = useUserApi();
const toast = useNotifier();
const importCsv = ref<File | null>(null);
const importLoading = ref(false);
const importRef = ref<HTMLInputElement>();
whenever(
() => !dialog.value,
() => {
importCsv.value = null;
}
);
function setFile(e: Event) {
importCsv.value = e.target.files[0];
}
function uploadCsv() {
importRef.value.click();
}
const eventBus = useEventBus();
async function submitCsvFile() {
if (!importCsv.value) {
toast.error("Please select a file to import.");
return;
}
importLoading.value = true;
const { error } = await api.items.import(importCsv.value);
if (error) {
toast.error("Import failed. Please try again later.");
}
// Reset
dialog.value = false;
importLoading.value = false;
importCsv.value = null;
if (importRef.value) {
importRef.value.value = "";
}
eventBus.emit(EventTypes.ClearStores);
}
</script>

View file

@ -8,7 +8,7 @@
</script> </script>
<template> <template>
<component :is="cmp" class="container max-w-6xl mx-auto px-4"> <component :is="cmp" class="container max-w-6xl mx-auto px-3">
<slot /> <slot />
</component> </component>
</template> </template>

View file

@ -0,0 +1,94 @@
<template>
<DoughnutChart
:chart-options="chartOptions"
:chart-data="chartData"
:chart-id="chartId"
:dataset-id-key="datasetIdKey"
:css-classes="cssClasses"
:styles="styles"
:width="width"
:height="height"
/>
</template>
<script lang="ts">
import { Doughnut as DoughnutChart } from "vue-chartjs";
import { Chart as ChartJS, Title, Tooltip, Legend, CategoryScale, LinearScale, ArcElement } from "chart.js";
import { TChartData } from "vue-chartjs/dist/types";
ChartJS.register(Title, Tooltip, Legend, CategoryScale, LinearScale, ArcElement);
export default defineComponent({
name: "BarChart",
components: {
DoughnutChart,
},
props: {
chartId: {
type: String,
default: "bar-chart",
},
datasetIdKey: {
type: String,
default: "label",
},
width: {
type: Number,
default: 400,
},
height: {
type: Number,
default: 400,
},
cssClasses: {
default: "",
type: String,
},
styles: {
type: Object,
default: () => {
return {};
},
},
chartData: {
type: Object as () => TChartData<"doughnut", number[], unknown>,
default: () => {
return {
labels: ["Red", "Blue", "Yellow"],
datasets: [
{
label: "My First Dataset",
data: [300, 50, 100],
backgroundColor: ["rgb(255, 99, 132)", "rgb(54, 162, 235)", "rgb(255, 205, 86)"],
hoverOffset: 4,
},
],
};
},
},
},
data() {
return {
chartOptions: {
responsive: false,
// Legend on the left
plugins: {
legend: {
position: "bottom",
},
// Display percentage
// tooltip: {
// callbacks: {
// label: context => {
// const label = context.dataset?.label || "";
// const value = context.parsed.y;
// return `${label}: ${value}%`;
// },
// },
// },
},
},
};
},
});
</script>

View file

@ -0,0 +1,114 @@
<template>
<div ref="el" class="min-h-full flex flex-col">
{{ styles }}
<LineChart :chart-options="options" :chart-data="chartData" :styles="styles" />
</div>
</template>
<script lang="ts">
import { Line as LineChart } from "vue-chartjs";
import {
Chart as ChartJS,
PointElement,
Title,
Tooltip,
Legend,
CategoryScale,
LinearScale,
LineElement,
} from "chart.js";
import { TChartData } from "vue-chartjs/dist/types";
ChartJS.register(Title, Tooltip, Legend, CategoryScale, LinearScale, PointElement, LineElement);
export default defineComponent({
name: "BarChart",
components: {
LineChart,
},
props: {
chartId: {
type: String,
default: "bar-chart",
},
datasetIdKey: {
type: String,
default: "label",
},
cssClasses: {
default: "",
type: String,
},
chartData: {
type: Object as () => TChartData<"line", number[], unknown>,
default: () => {
return {
labels: ["January", "February", "March"],
datasets: [{ data: [40, 20, 12] }],
};
},
},
},
setup() {
const el = ref<HTMLElement | null>(null);
const calcHeight = ref(0);
const calcWidth = ref(0);
function resize() {
console.log("resize", el.value?.offsetHeight, el.value?.offsetWidth);
calcHeight.value = el.value?.offsetHeight || 0;
calcWidth.value = el.value?.offsetWidth || 0;
}
onMounted(() => {
resize();
window.addEventListener("resize", resize);
});
onUnmounted(() => {
window.removeEventListener("resize", resize);
});
const styles = computed(() => {
return {
height: `${calcHeight.value}px`,
width: `${calcWidth.value}px`,
position: "relative",
};
});
return {
el,
parentHeight: calcHeight,
styles,
};
},
data() {
return {
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
display: false,
},
y: {
display: true,
},
},
elements: {
line: {
borderWidth: 5,
},
point: {
radius: 4,
},
},
},
};
},
});
</script>
<style></style>

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { LabelOut, LabelSummary } from "~~/lib/api/types/data-contracts"; import { LabelOut, LabelSummary } from "~~/lib/api/types/data-contracts";
export type sizes = "sm" | "md" | "lg"; export type sizes = "sm" | "md" | "lg" | "xl";
defineProps({ defineProps({
label: { label: {
type: Object as () => LabelOut | LabelSummary, type: Object as () => LabelOut | LabelSummary,
@ -23,9 +23,10 @@
<template> <template>
<NuxtLink <NuxtLink
ref="badge" ref="badge"
class="badge" class="badge badge-secondary text-secondary-content"
:class="{ :class="{
'p-3': size !== 'sm', 'badge-lg p-4': size === 'lg',
'p-3': size !== 'sm' && size !== 'lg',
'p-2 badge-sm': size === 'sm', 'p-2 badge-sm': size === 'sm',
}" }"
:to="`/label/${label.id}`" :to="`/label/${label.id}`"

View file

@ -2,7 +2,7 @@
<NuxtLink <NuxtLink
ref="card" ref="card"
:to="`/location/${location.id}`" :to="`/location/${location.id}`"
class="card bg-primary text-primary-content transition duration-300" class="card bg-base-100 text-base-content rounded-md transition duration-300 shadow-md"
> >
<div <div
class="card-body" class="card-body"
@ -11,13 +11,15 @@
'py-2 px-3': dense, 'py-2 px-3': dense,
}" }"
> >
<h2 class="flex items-center gap-2"> <h2 class="flex items-center justify-between gap-2">
<label class="swap swap-rotate" :class="isActive ? 'swap-active' : ''"> <label class="swap swap-rotate" :class="isActive ? 'swap-active' : ''">
<Icon name="heroicons-arrow-right" class="swap-on" /> <Icon name="heroicons-arrow-right" class="swap-on h-6 w-6" />
<Icon name="heroicons-map-pin" class="swap-off" /> <Icon name="heroicons-map-pin" class="swap-off h-6 w-6" />
</label> </label>
{{ location.name }} <span class="mx-auto">
<span v-if="hasCount" class="badge badge-secondary badge-lg ml-auto text-secondary-content"> {{ count }}</span> {{ location.name }}
</span>
<span v-if="hasCount" class="badge badge-primary h-6 badge-lg"> {{ count }}</span>
</h2> </h2>
</div> </div>
</NuxtLink> </NuxtLink>

View file

@ -3,18 +3,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps({ type Props = {
amount: { amount: string | number;
type: String, };
required: true,
}, const props = defineProps<Props>();
});
const fmt = await useFormatCurrency(); const fmt = await useFormatCurrency();
const value = computed(() => { const value = computed(() => {
if (!props.amount || props.amount === "0") { if (!props.amount || props.amount === "0") {
return ""; return fmt(0);
} }
return fmt(props.amount); return fmt(props.amount);

View file

@ -1,2 +0,0 @@
import DetailsSection from "./DetailsSection.vue";
export default DetailsSection;

View file

@ -0,0 +1,28 @@
<template>
<div class="stats bg-neutral shadow rounded-md">
<div class="stat text-neutral-content text-center space-y-1 p-3">
<div class="stat-title">{{ title }}</div>
<div class="stat-value text-2xl">
<Currency v-if="type === 'currency'" :amount="value" />
<template v-if="type === 'number'">{{ value }}</template>
</div>
<div v-if="subtitle" class="stat-desc">{{ subtitle }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { StatsFormat } from "./types";
type Props = {
title: string;
value: number;
subtitle?: string;
type?: StatsFormat;
};
withDefaults(defineProps<Props>(), {
type: "number",
subtitle: undefined,
});
</script>

View file

@ -0,0 +1 @@
export type StatsFormat = "currency" | "number" | "percent";

View file

@ -0,0 +1,5 @@
<template>
<h3 class="flex gap-2 items-center mb-3 pl-1 text-lg">
<slot />
</h3>
</template>

View file

@ -0,0 +1,8 @@
export type TableHeader = {
text: string;
value: string;
sortable?: boolean;
align?: "left" | "center" | "right";
};
export type TableData = Record<string, any>;

View file

@ -0,0 +1,68 @@
<template>
<div class="overflow-x-auto">
<table class="table w-full">
<thead>
<tr class="bg-primary">
<th
v-for="h in headers"
:key="h.value"
class="text-no-transform text-sm bg-neutral text-neutral-content"
:class="{
'text-center': h.align === 'center',
'text-right': h.align === 'right',
'text-left': h.align === 'left',
}"
>
<template v-if="typeof h === 'string'">{{ h }}</template>
<template v-else>{{ h.text }}</template>
</th>
</tr>
</thead>
<tbody>
<!-- row 1 -->
<tr v-for="(d, i) in data" :key="i">
<td
v-for="h in headers"
:key="`${h.value}-${i}`"
class="bg-base-100"
:class="{
'text-center': h.align === 'center',
'text-right': h.align === 'right',
'text-left': h.align === 'left',
}"
>
<slot :name="cell(h)" v-bind="{ item: d }">
{{ extractValue(d, h.value) }}
</slot>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { TableData, TableHeader } from "./Table.types";
type Props = {
headers: TableHeader[];
data: TableData[];
};
function extractValue(data: TableData, value: string) {
const parts = value.split(".");
let current = data;
for (const part of parts) {
current = current[part];
}
return current;
}
function cell(h: TableHeader) {
return `cell-${h.value.replace(".", "_")}`;
}
defineProps<Props>();
</script>
<style scoped></style>

View file

@ -32,6 +32,13 @@ export function useConfirm(): Store {
} }
async function openDialog(msg: string): Promise<UseConfirmDialogRevealResult<boolean, boolean>> { async function openDialog(msg: string): Promise<UseConfirmDialogRevealResult<boolean, boolean>> {
if (!store.reveal) {
throw new Error("reveal is not defined");
}
if (!store.text) {
throw new Error("text is not defined");
}
store.text.value = msg; store.text.value = msg;
return await store.reveal(); return await store.reveal();
} }

View file

@ -0,0 +1,126 @@
type ColorType = "hsla";
export type VarOptions = {
type: ColorType;
transparency?: number;
apply?: (value: string) => string;
};
export type Breakpoints = {
sm: boolean;
md: boolean;
lg: boolean;
xl: boolean;
xxl: boolean;
};
export function useBreakpoints(): Breakpoints {
const breakpoints: Breakpoints = reactive({
sm: false,
md: false,
lg: false,
xl: false,
xxl: false,
});
const updateBreakpoints = () => {
breakpoints.sm = window.innerWidth < 640;
breakpoints.md = window.innerWidth >= 640;
breakpoints.lg = window.innerWidth >= 768;
breakpoints.xl = window.innerWidth >= 1024;
breakpoints.xxl = window.innerWidth >= 1280;
};
onMounted(() => {
updateBreakpoints();
window.addEventListener("resize", updateBreakpoints);
});
onUnmounted(() => {
window.removeEventListener("resize", updateBreakpoints);
});
return breakpoints;
}
class ThemeObserver {
// eslint-disable-next-line no-use-before-define
private static instance?: ThemeObserver;
private readonly observer: MutationObserver;
private fns: (() => void)[] = [];
private constructor() {
this.observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.attributeName === "data-theme") {
this.fire();
}
});
});
const html = document.querySelector("html");
if (!html) {
throw new Error("No html element found");
}
this.observer.observe(html, { attributes: true });
}
public static getInstance() {
if (!ThemeObserver.instance) {
ThemeObserver.instance = new ThemeObserver();
}
return ThemeObserver.instance;
}
private fire() {
this.fns.forEach(fn => fn());
}
public add(fn: () => void) {
this.fns.push(fn);
}
public remove(fn: () => void) {
this.fns = this.fns.filter(f => f !== fn);
}
}
export function useCssVar(name: string, options?: VarOptions) {
if (!options) {
options = {
type: "hsla",
transparency: 1,
apply: undefined,
};
}
const cssVal = ref(getComputedStyle(document.documentElement).getPropertyValue(name).trim());
const update = () => {
cssVal.value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
};
ThemeObserver.getInstance().add(update);
onUnmounted(() => {
ThemeObserver.getInstance().remove(update);
});
switch (options.type) {
case "hsla": {
return computed(() => {
if (!document) {
return "";
}
let val = cssVal.value.trim().split(" ").join(", ");
if (options?.transparency) {
val += `, ${options.transparency}`;
}
return `hsla(${val})`;
});
}
}
}

View file

@ -1,20 +1,174 @@
<template> <template>
<div> <div>
<!--
Confirmation Modal is a singleton used by all components so we render
it here to ensure it's always available. Possibly could move this further
up the tree
-->
<ModalConfirm />
<AppImportDialog v-model="modals.import" />
<ItemCreateModal v-model="modals.item" />
<LabelCreateModal v-model="modals.label" />
<LocationCreateModal v-model="modals.location" />
<AppToast /> <AppToast />
<AppHeader /> <div class="drawer drawer-mobile">
<main> <input id="my-drawer-2" v-model="drawerToggle" type="checkbox" class="drawer-toggle" />
<slot></slot> <div class="drawer-content justify-center bg-base-300">
</main> <AppHeaderDecor class="-mt-10" />
<slot></slot>
<!-- Button -->
<label for="my-drawer-2" class="btn btn-primary drawer-button lg:hidden fixed bottom-2 right-2">
<Icon name="mdi-menu" class="h-6 w-6" />
</label>
</div>
<!-- Sidebar -->
<div class="drawer-side shadow-lg">
<label for="my-drawer-2" class="drawer-overlay"></label>
<!-- Top Section -->
<div class="w-60 py-5 md:py-10 bg-base-200 flex flex-grow-1 flex-col">
<div class="space-y-8">
<div class="flex flex-col items-center gap-4">
<p>Welcome, {{ username }}</p>
<NuxtLink class="avatar placeholder" to="/home">
<div class="bg-base-300 text-neutral-content rounded-full w-24 p-4">
<AppLogo />
</div>
</NuxtLink>
</div>
<div class="flex flex-col bg-base-200">
<div class="mx-auto w-40 mb-6">
<div class="dropdown overflow visible w-40">
<label tabindex="0" class="btn btn-primary btn-block text-lg text-no-transform">
<span>
<Icon name="mdi-plus" class="mr-1 -ml-1" />
</span>
Create
</label>
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-40">
<li v-for="btn in dropdown" :key="btn.name">
<button @click="btn.action">
{{ btn.name }}
</button>
</li>
</ul>
</div>
</div>
<ul class="flex flex-col mx-auto gap-2 w-40 menu">
<li v-for="n in nav" :key="n.id" class="text-xl" @click="unfocus">
<NuxtLink
v-if="n.to"
class="rounded-btn"
:to="n.to"
:class="{
'bg-secondary text-secondary-content': n.active?.value,
}"
>
<Icon :name="n.icon" class="h-6 w-6 mr-4" />
{{ n.name }}
</NuxtLink>
<button v-else class="rounded-btn" @click="n.action">
<Icon :name="n.icon" class="h-6 w-6 mr-4" />
{{ n.name }}
</button>
</li>
</ul>
</div>
</div>
<!-- Bottom -->
<button class="mt-auto mx-2 hover:bg-base-300 p-3 rounded-btn" @click="logout">Sign Out</button>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useAuthStore } from "~~/stores/auth";
import { useLabelStore } from "~~/stores/labels"; import { useLabelStore } from "~~/stores/labels";
import { useLocationStore } from "~~/stores/locations"; import { useLocationStore } from "~~/stores/locations";
/** const username = computed(() => authStore.self?.name || "User");
* Store Provider Initialization
*/ const modals = reactive({
item: false,
location: false,
label: false,
import: false,
});
const dropdown = [
{
name: "Item / Asset",
action: () => {
modals.item = true;
},
},
{
name: "Location",
action: () => {
modals.location = true;
},
},
{
name: "Label",
action: () => {
modals.label = true;
},
},
];
const route = useRoute();
const drawerToggle = ref();
function unfocus() {
// unfocus current element
drawerToggle.value = false;
}
const nav = [
{
icon: "mdi-home",
active: computed(() => route.path === "/home"),
id: 0,
name: "Home",
to: "/home",
},
{
icon: "mdi-account",
id: 1,
active: computed(() => route.path === "/profile"),
name: "Profile",
to: "/profile",
},
{
icon: "mdi-document",
id: 3,
active: computed(() => route.path === "/items"),
name: "Items",
to: "/items",
},
{
icon: "mdi-database",
id: 2,
name: "Import",
action: () => {
modals.import = true;
},
},
// {
// icon: "mdi-database-export",
// id: 5,
// name: "Export",
// action: () => {
// console.log("Export");
// },
// },
];
const labelStore = useLabelStore(); const labelStore = useLabelStore();
const reLabel = /\/api\/v1\/labels\/.*/gm; const reLabel = /\/api\/v1\/labels\/.*/gm;
@ -55,4 +209,16 @@
rmLocationStoreObserver(); rmLocationStoreObserver();
eventBus.off(EventTypes.ClearStores, "stores"); eventBus.off(EventTypes.ClearStores, "stores");
}); });
const authStore = useAuthStore();
const api = useUserApi();
async function logout() {
const { error } = await authStore.logout(api);
if (error) {
return;
}
navigateTo("/");
}
</script> </script>

View file

@ -120,6 +120,8 @@ export interface ItemSummary {
/** Edges */ /** Edges */
location: LocationSummary | null; location: LocationSummary | null;
name: string; name: string;
/** @example "0" */
purchasePrice: string;
quantity: number; quantity: number;
updatedAt: Date; updatedAt: Date;
} }

View file

@ -1,4 +1,5 @@
export type DaisyTheme = export type DaisyTheme =
| "homebox"
| "light" | "light"
| "dark" | "dark"
| "cupcake" | "cupcake"
@ -35,6 +36,10 @@ export type ThemeOption = {
}; };
export const themes: ThemeOption[] = [ export const themes: ThemeOption[] = [
{
label: "Homebox",
value: "homebox",
},
{ {
label: "Garden", label: "Garden",
value: "garden", value: "garden",

View file

@ -9,8 +9,9 @@ export default defineNuxtConfig({
"/api": { "/api": {
target: "http://localhost:7745/api", target: "http://localhost:7745/api",
changeOrigin: true, changeOrigin: true,
} },
}, },
}, },
plugins: [], css: ["@/assets/css/main.css"],
plugins: [],
}); });

View file

@ -36,12 +36,14 @@
"@tailwindcss/typography": "^0.5.4", "@tailwindcss/typography": "^0.5.4",
"@vueuse/nuxt": "^9.1.1", "@vueuse/nuxt": "^9.1.1",
"autoprefixer": "^10.4.8", "autoprefixer": "^10.4.8",
"chart.js": "^4.0.1",
"daisyui": "^2.24.0", "daisyui": "^2.24.0",
"dompurify": "^2.4.1", "dompurify": "^2.4.1",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"pinia": "^2.0.21", "pinia": "^2.0.21",
"postcss": "^8.4.16", "postcss": "^8.4.16",
"tailwindcss": "^3.1.8", "tailwindcss": "^3.1.8",
"vue": "^3.2.38" "vue": "^3.2.38",
"vue-chartjs": "^4.1.2"
} }
} }

View file

@ -1,166 +0,0 @@
<script setup lang="ts">
import { useAuthStore } from "~~/stores/auth";
import { useLabelStore } from "~~/stores/labels";
import { useLocationStore } from "~~/stores/locations";
definePageMeta({
middleware: ["auth"],
});
useHead({
title: "Homebox | Home",
});
const api = useUserApi();
const auth = useAuthStore();
const locationStore = useLocationStore();
const locations = computed(() => locationStore.parentLocations);
const labelsStore = useLabelStore();
const labels = computed(() => labelsStore.labels);
const { data: statistics } = useAsyncData(async () => {
const { data } = await api.stats.group();
return data;
});
const stats = computed(() => {
return [
{
label: "Locations",
value: statistics.value?.totalLocations || 0,
},
{
label: "Items",
value: statistics.value?.totalItems || 0,
},
{
label: "Labels",
value: statistics.value?.totalLabels || 0,
},
];
});
const importDialog = ref(false);
const importCsv = ref(null);
const importLoading = ref(false);
const importRef = ref<HTMLInputElement>();
whenever(
() => !importDialog.value,
() => {
importCsv.value = null;
}
);
function setFile(e: Event & { target: HTMLInputElement }) {
importCsv.value = e.target.files[0];
}
const toast = useNotifier();
function openDialog() {
importDialog.value = true;
}
function uploadCsv() {
importRef.value.click();
}
const eventBus = useEventBus();
async function submitCsvFile() {
importLoading.value = true;
const { error } = await api.items.import(importCsv.value);
if (error) {
toast.error("Import failed. Please try again later.");
}
// Reset
importDialog.value = false;
importLoading.value = false;
importCsv.value = null;
importRef.value.value = null;
eventBus.emit(EventTypes.ClearStores);
}
</script>
<template>
<div>
<BaseModal v-model="importDialog">
<template #title> Import CSV File </template>
<p>
Import a CSV file containing your items, labels, and locations. See documentation for more information on the
required format.
</p>
<form @submit.prevent="submitCsvFile">
<div class="flex flex-col gap-2 py-6">
<input ref="importRef" type="file" class="hidden" accept=".csv,.tsv" @change="setFile" />
<BaseButton type="button" @click="uploadCsv">
<Icon class="h-5 w-5 mr-2" name="mdi-upload" />
Upload
</BaseButton>
<p class="text-center pt-4 -mb-5">
{{ importCsv?.name }}
</p>
</div>
<div class="modal-action">
<BaseButton type="submit" :disabled="!importCsv"> Submit </BaseButton>
</div>
</form>
</BaseModal>
<BaseContainer class="flex flex-col gap-16 pb-16">
<section>
<BaseCard>
<template #title> Welcome Back, {{ auth.self ? auth.self.name : "Username" }} </template>
<!-- <template #subtitle> {{ auth.self.isSuperuser ? "Admin" : "User" }} </template> -->
<template #title-actions>
<div class="flex justify-end gap-2">
<div class="tooltip" data-tip="Import CSV File">
<button class="btn btn-primary btn-sm" @click="openDialog">
<Icon name="mdi-database" class="mr-2"></Icon>
Import
</button>
</div>
<BaseButton type="button" size="sm" to="/profile">
<Icon class="h-5 w-5 mr-2" name="mdi-person" />
Profile
</BaseButton>
</div>
</template>
<div
class="grid grid-cols-1 divide-y divide-base-300 border-t border-base-300 sm:grid-cols-3 sm:divide-y-0 sm:divide-x"
>
<div v-for="stat in stats" :key="stat.label" class="px-6 py-5 text-center text-sm font-medium">
<span class="text-base-900 font-bold">{{ stat.value }}</span>
{{ " " }}
<span class="text-base-600">{{ stat.label }}</span>
</div>
</div>
</BaseCard>
</section>
<section>
<BaseSectionHeader class="mb-5"> Storage Locations </BaseSectionHeader>
<div class="grid grid-cols-1 sm:grid-cols-2 card md:grid-cols-3 gap-4">
<LocationCard v-for="location in locations" :key="location.id" :location="location" />
</div>
</section>
<section>
<BaseSectionHeader class="mb-5"> Labels </BaseSectionHeader>
<div class="flex gap-2 flex-wrap">
<LabelChip v-for="label in labels" :key="label.id" size="lg" :label="label" />
</div>
</section>
</BaseContainer>
</div>
</template>

View file

@ -0,0 +1,71 @@
import { TChartData } from "vue-chartjs/dist/types";
import { UserClient } from "~~/lib/api/user";
export function purchasePriceOverTimeChart(api: UserClient) {
const { data: timeseries } = useAsyncData(async () => {
const { data } = await api.stats.totalPriceOverTime();
return data;
});
const primary = useCssVar("--p");
return computed(() => {
if (!timeseries.value) {
return {
labels: ["Purchase Price"],
datasets: [
{
label: "Purchase Price",
data: [],
backgroundColor: primary.value,
borderColor: primary.value,
},
],
} as TChartData<"line", number[], unknown>;
}
let start = timeseries.value?.valueAtStart;
return {
labels: timeseries?.value.entries.map(t => new Date(t.date).toDateString()) || [],
datasets: [
{
label: "Purchase Price",
data:
timeseries.value?.entries.map(t => {
start += t.value;
return start;
}) || [],
backgroundColor: primary.value,
borderColor: primary.value,
},
],
} as TChartData<"line", number[], unknown>;
});
}
export function inventoryByLocationChart(api: UserClient) {
const { data: donutSeries } = useAsyncData(async () => {
const { data } = await api.stats.locations();
return data;
});
const primary = useCssVar("--p");
const secondary = useCssVar("--s");
const neutral = useCssVar("--n");
return computed(() => {
return {
labels: donutSeries.value?.map(l => l.name) || [],
datasets: [
{
label: "Value",
data: donutSeries.value?.map(l => l.total) || [],
backgroundColor: [primary.value, secondary.value, neutral.value],
borderColor: [primary.value, secondary.value, neutral.value],
hoverOffset: 4,
},
],
};
});
}

View file

@ -0,0 +1,120 @@
<script setup lang="ts">
import { statCardData } from "./statistics";
import { itemsTable } from "./table";
import { useLabelStore } from "~~/stores/labels";
import { useLocationStore } from "~~/stores/locations";
definePageMeta({
middleware: ["auth"],
});
useHead({
title: "Homebox | Home",
});
const api = useUserApi();
const breakpoints = useBreakpoints();
const locationStore = useLocationStore();
const locations = computed(() => locationStore.parentLocations);
const labelsStore = useLabelStore();
const labels = computed(() => labelsStore.labels);
const itemTable = itemsTable(api);
const stats = statCardData(api);
// const purchasePriceOverTime = purchasePriceOverTimeChart(api);
// const inventoryByLocation = inventoryByLocationChart(api);
// const refDonutEl = ref<HTMLDivElement>();
// const donutElWidth = computed(() => {
// return refDonutEl.value?.clientWidth || 0;
// });
</script>
<template>
<div>
<BaseContainer class="flex flex-col gap-12 pb-16">
<!-- <section class="grid grid-cols-6 gap-6">
<article class="col-span-4">
<Subtitle> Inventory Value Over Time </Subtitle>
<BaseCard>
<div class="p-10 h-[300px]">
<ClientOnly>
<ChartLine :chart-data="purchasePriceOverTime" />
</ClientOnly>
</div>
</BaseCard>
</article>
<article class="col-span-2">
<Subtitle>
Inventory By
<span class="btn-group">
<button class="btn btn-xs btn-active text-no-transform">Locations</button>
<button class="btn btn-xs text-no-transform">Labels</button>
</span>
</Subtitle>
<BaseCard class="h-[300px]">
<div ref="refDonutEl" class="grid place-content-center h-full">
<ClientOnly>
<ChartDonut
chart-id="donut"
:width="donutElWidth - 50"
:height="265"
:chart-data="inventoryByLocation"
/>
</ClientOnly>
</div>
</BaseCard>
</article>
</section> -->
<section>
<Subtitle> Quick Statistics </Subtitle>
<div class="grid grid-cols-2 gap-2 md:grid-cols-4 md:gap-6">
<StatCard v-for="(stat, i) in stats" :key="i" :title="stat.label" :value="stat.value" :type="stat.type" />
</div>
</section>
<section>
<Subtitle> Recently Added </Subtitle>
<BaseCard v-if="breakpoints.lg">
<Table :headers="itemTable.headers" :data="itemTable.items">
<template #cell-warranty="{ item }">
<Icon v-if="item.warranty" name="mdi-check" class="text-green-500 h-5 w-5" />
<Icon v-else name="mdi-close" class="text-red-500 h-5 w-5" />
</template>
<template #cell-purchasePrice="{ item }">
<Currency :amount="item.purchasePrice" />
</template>
<template #cell-location_Name="{ item }">
<NuxtLink class="badge badge-sm badge-primary p-3" :to="`/location/${item.location.id}`">
{{ item.location?.name }}
</NuxtLink>
</template>
</Table>
</BaseCard>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-4">
<ItemCard v-for="item in itemTable.items" :key="item.id" :item="item" />
</div>
</section>
<section>
<Subtitle> Storage Locations </Subtitle>
<div class="grid grid-cols-1 sm:grid-cols-2 card md:grid-cols-3 gap-4">
<LocationCard v-for="location in locations" :key="location.id" :location="location" />
</div>
</section>
<section>
<Subtitle> Labels </Subtitle>
<div class="flex gap-4 flex-wrap">
<LabelChip v-for="label in labels" :key="label.id" size="lg" :label="label" class="shadow-md" />
</div>
</section>
</BaseContainer>
</div>
</template>

View file

@ -0,0 +1,39 @@
import { UserClient } from "~~/lib/api/user";
type StatCard = {
label: string;
value: number;
type: "currency" | "number";
};
export function statCardData(api: UserClient) {
const { data: statistics } = useAsyncData(async () => {
const { data } = await api.stats.group();
return data;
});
return computed(() => {
return [
{
label: "Total Value",
value: statistics.value?.totalItemPrice || 0,
type: "currency",
},
{
label: "Total Items",
value: statistics.value?.totalItems || 0,
type: "number",
},
{
label: "Total Locations",
value: statistics.value?.totalLocations || 0,
type: "number",
},
{
label: "Total Labels",
value: statistics.value?.totalLabels || 0,
type: "number",
},
] as StatCard[];
});
}

View file

@ -0,0 +1,42 @@
import { TableHeader } from "~~/components/global/Table.types";
import { UserClient } from "~~/lib/api/user";
export function itemsTable(api: UserClient) {
const { data: items } = useAsyncData(async () => {
const { data } = await api.items.getAll({
page: 1,
pageSize: 5,
});
return data.items;
});
const headers = [
{
text: "Name",
sortable: true,
value: "name",
},
{
text: "Location",
value: "location.name",
},
{
text: "Warranty",
value: "warranty",
align: "center",
},
{
text: "Price",
value: "purchasePrice",
align: "center",
},
] as TableHeader[];
return computed(() => {
return {
headers,
items: items.value || [],
};
});
}

View file

@ -343,8 +343,8 @@
<img class="max-w-[80vw] max-h-[80vh]" :src="dialoged.src" /> <img class="max-w-[80vw] max-h-[80vh]" :src="dialoged.src" />
</div> </div>
</dialog> </dialog>
<section class="px-3"> <section>
<div class="space-y-3"> <div class="space-y-6">
<BaseCard> <BaseCard>
<template #title> <template #title>
<BaseSectionHeader> <BaseSectionHeader>
@ -374,35 +374,31 @@
</BaseSectionHeader> </BaseSectionHeader>
</template> </template>
<template #title-actions> <template #title-actions>
<div class="modal-action mt-0"> <div class="flex flex-wrap justify-between items-center mt-2 gap-4">
<label v-if="!hasNested" class="label cursor-pointer mr-auto"> <label v-if="!hasNested" class="label cursor-pointer">
<input v-model="preferences.showEmpty" type="checkbox" class="toggle toggle-primary" /> <input v-model="preferences.showEmpty" type="checkbox" class="toggle toggle-primary" />
<span class="label-text ml-4"> Show Empty </span> <span class="label-text ml-4"> Show Empty </span>
</label> </label>
<BaseButton v-else class="mr-auto" size="sm" @click="$router.go(-1)"> <div class="flex flex-wrap justify-end gap-2 ml-auto">
<template #icon> <BaseButton size="sm" :to="`/item/${itemId}/edit`">
<Icon name="mdi-arrow-left" class="h-5 w-5" /> <template #icon>
</template> <Icon name="mdi-pencil" />
Back </template>
</BaseButton> Edit
<BaseButton size="sm" :to="`/item/${itemId}/edit`"> </BaseButton>
<template #icon> <BaseButton size="sm" @click="deleteItem">
<Icon name="mdi-pencil" /> <template #icon>
</template> <Icon name="mdi-delete" />
Edit </template>
</BaseButton> Delete
<BaseButton size="sm" @click="deleteItem"> </BaseButton>
<template #icon> <BaseButton size="sm" :to="`/item/${itemId}/log`">
<Icon name="mdi-delete" /> <template #icon>
</template> <Icon name="mdi-post" />
Delete </template>
</BaseButton> Log
<BaseButton size="sm" :to="`/item/${itemId}/log`"> </BaseButton>
<template #icon> </div>
<Icon name="mdi-post" />
</template>
Log
</BaseButton>
</div> </div>
</template> </template>
@ -410,7 +406,7 @@
</BaseCard> </BaseCard>
<NuxtPage :item="item" :page-key="itemId" /> <NuxtPage :item="item" :page-key="itemId" />
<div v-if="!hasNested"> <template v-if="!hasNested">
<BaseCard v-if="photos && photos.length > 0"> <BaseCard v-if="photos && photos.length > 0">
<template #title> Photos </template> <template #title> Photos </template>
<div <div
@ -470,7 +466,7 @@
<template #title> Sold Details </template> <template #title> Sold Details </template>
<DetailsSection :details="soldDetails" /> <DetailsSection :details="soldDetails" />
</BaseCard> </BaseCard>
</div> </template>
</div> </div>
</section> </section>

View file

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import DatePicker from "~~/components/Form/DatePicker.vue"; import DatePicker from "~~/components/Form/DatePicker.vue";
import { StatsFormat } from "~~/components/global/StatCard/types";
import { ItemOut } from "~~/lib/api/types/data-contracts"; import { ItemOut } from "~~/lib/api/types/data-contracts";
const props = defineProps<{ const props = defineProps<{
@ -14,21 +15,31 @@
return data; return data;
}); });
const count = computed(() => {
if (!log.value) return 0;
return log.value.entries.length;
});
const stats = computed(() => { const stats = computed(() => {
if (!log.value) return []; if (!log.value) return [];
return [ return [
{
id: "count",
title: "Total Entries",
value: count.value || 0,
type: "number" as StatsFormat,
},
{ {
id: "total", id: "total",
title: "Total Cost", title: "Total Cost",
subtitle: "Sum over all entries", value: log.value.costTotal || 0,
value: fmtCurrency(log.value.costTotal), type: "currency" as StatsFormat,
}, },
{ {
id: "average", id: "average",
title: "Monthly Average", title: "Monthly Average",
subtitle: "Average over all entries", value: log.value.costAverage || 0,
value: fmtCurrency(log.value.costAverage), type: "currency" as StatsFormat,
}, },
]; ];
}); });
@ -63,14 +74,20 @@
refreshLog(); refreshLog();
} }
const confirm = useConfirm();
async function deleteEntry(id: string) { async function deleteEntry(id: string) {
const result = await confirm.open("Are you sure you want to delete this entry?");
if (result.isCanceled) {
return;
}
const { error } = await api.items.maintenance.delete(props.item.id, id); const { error } = await api.items.maintenance.delete(props.item.id, id);
if (error) { if (error) {
toast.error("Failed to delete entry"); toast.error("Failed to delete entry");
return; return;
} }
refreshLog(); refreshLog();
} }
</script> </script>
@ -95,16 +112,32 @@
</form> </form>
</BaseModal> </BaseModal>
<div class="flex"> <section class="space-y-6">
<BaseButton class="ml-auto" size="sm" @click="newEntry()"> <div class="flex">
<template #icon> <BaseButton size="sm" @click="$router.go(-1)">
<Icon name="mdi-post" /> <template #icon>
</template> <Icon name="mdi-arrow-left" class="h-5 w-5" />
Log Maintenance </template>
</BaseButton> Back
</div> </BaseButton>
<section class="page-layout my-6"> <BaseButton class="ml-auto" size="sm" @click="newEntry()">
<div class="main-slot container space-y-6"> <template #icon>
<Icon name="mdi-post" />
</template>
Log Maintenance
</BaseButton>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<StatCard
v-for="stat in stats"
:key="stat.id"
class="stats block shadow-xl border-l-primary"
:title="stat.title"
:value="stat.value"
:type="stat.type"
/>
</div>
<div class="container space-y-6">
<BaseCard v-for="e in log.entries" :key="e.id"> <BaseCard v-for="e in log.entries" :key="e.id">
<BaseSectionHeader class="p-6 border-b border-b-gray-300"> <BaseSectionHeader class="p-6 border-b border-b-gray-300">
<span class="text-base-content"> <span class="text-base-content">
@ -137,37 +170,6 @@
</div> </div>
</BaseCard> </BaseCard>
</div> </div>
<div class="side-slot space-y-6">
<div v-for="stat in stats" :key="stat.id" class="stats block shadow-xl border-l-primary">
<div class="stat">
<div class="stat-title">{{ stat.title }}</div>
<div class="stat-value text-primary">{{ stat.value }}</div>
<div class="stat-desc">{{ stat.subtitle }}</div>
</div>
</div>
</div>
</section> </section>
</div> </div>
</template> </template>
<style scoped>
.page-layout {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto;
grid-template-areas: "side main";
gap: 1rem;
}
.side-slot {
grid-area: side;
}
.main-slot {
grid-area: main;
}
.grid {
display: grid;
}
</style>

View file

@ -135,7 +135,7 @@
<div class="flex mt-1"> <div class="flex mt-1">
<label class="ml-auto label cursor-pointer"> <label class="ml-auto label cursor-pointer">
<input v-model="advanced" type="checkbox" class="toggle toggle-primary" /> <input v-model="advanced" type="checkbox" class="toggle toggle-primary" />
<span class="label-text text-neutral-content ml-2"> Filters </span> <span class="label-text text-base-content ml-2"> Filters </span>
</label> </label>
</div> </div>
<BaseCard v-if="advanced" class="my-1 overflow-visible"> <BaseCard v-if="advanced" class="my-1 overflow-visible">

View file

@ -22,8 +22,6 @@
if (group.value) { if (group.value) {
group.value.currency = currency.value.code; group.value.currency = currency.value.code;
} }
console.log(group.value);
}); });
const currencyExample = computed(() => { const currencyExample = computed(() => {

View file

@ -13,6 +13,7 @@ specifiers:
'@typescript-eslint/parser': ^5.36.2 '@typescript-eslint/parser': ^5.36.2
'@vueuse/nuxt': ^9.1.1 '@vueuse/nuxt': ^9.1.1
autoprefixer: ^10.4.8 autoprefixer: ^10.4.8
chart.js: ^4.0.1
daisyui: ^2.24.0 daisyui: ^2.24.0
dompurify: ^2.4.1 dompurify: ^2.4.1
eslint: ^8.23.0 eslint: ^8.23.0
@ -30,6 +31,7 @@ specifiers:
vite-plugin-eslint: ^1.8.1 vite-plugin-eslint: ^1.8.1
vitest: ^0.22.1 vitest: ^0.22.1
vue: ^3.2.38 vue: ^3.2.38
vue-chartjs: ^4.1.2
dependencies: dependencies:
'@iconify/vue': 3.2.1_vue@3.2.45 '@iconify/vue': 3.2.1_vue@3.2.45
@ -40,6 +42,7 @@ dependencies:
'@tailwindcss/typography': 0.5.8_tailwindcss@3.2.4 '@tailwindcss/typography': 0.5.8_tailwindcss@3.2.4
'@vueuse/nuxt': 9.6.0_nuxt@3.0.0+vue@3.2.45 '@vueuse/nuxt': 9.6.0_nuxt@3.0.0+vue@3.2.45
autoprefixer: 10.4.13_postcss@8.4.19 autoprefixer: 10.4.13_postcss@8.4.19
chart.js: 4.0.1
daisyui: 2.43.0_2lwn2upnx27dqeg6hqdu7sq75m daisyui: 2.43.0_2lwn2upnx27dqeg6hqdu7sq75m
dompurify: 2.4.1 dompurify: 2.4.1
markdown-it: 13.0.1 markdown-it: 13.0.1
@ -47,6 +50,7 @@ dependencies:
postcss: 8.4.19 postcss: 8.4.19
tailwindcss: 3.2.4_postcss@8.4.19 tailwindcss: 3.2.4_postcss@8.4.19
vue: 3.2.45 vue: 3.2.45
vue-chartjs: 4.1.2_chart.js@4.0.1+vue@3.2.45
devDependencies: devDependencies:
'@faker-js/faker': 7.6.0 '@faker-js/faker': 7.6.0
@ -1750,6 +1754,11 @@ packages:
/chardet/0.7.0: /chardet/0.7.0:
resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==}
/chart.js/4.0.1:
resolution: {integrity: sha512-5/8/9eBivwBZK81mKvmIwTb2Pmw4D/5h1RK9fBWZLLZ8mCJ+kfYNmV9rMrGoa5Hgy2/wVDBMLSUDudul2/9ihA==}
engines: {pnpm: ^7.0.0}
dev: false
/check-error/1.0.2: /check-error/1.0.2:
resolution: {integrity: sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==} resolution: {integrity: sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==}
dev: true dev: true
@ -6412,6 +6421,16 @@ packages:
dependencies: dependencies:
ufo: 1.0.1 ufo: 1.0.1
/vue-chartjs/4.1.2_chart.js@4.0.1+vue@3.2.45:
resolution: {integrity: sha512-QSggYjeFv/L4jFSBQpX8NzrAvX0B+Ha6nDgxkTG8tEXxYOOTwKI4phRLe+B4f+REnkmg7hgPY24R0cixZJyXBg==}
peerDependencies:
chart.js: ^3.7.0
vue: ^3.0.0-0 || ^2.6.0
dependencies:
chart.js: 4.0.1
vue: 3.2.45
dev: false
/vue-demi/0.13.11_vue@3.2.45: /vue-demi/0.13.11_vue@3.2.45:
resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==} resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==}
engines: {node: '>=12'} engines: {node: '>=12'}

View file

@ -4,6 +4,52 @@ module.exports = {
theme: { theme: {
extend: {}, extend: {},
}, },
daisyui: {
themes: [
{
homebox: {
primary: "#5C7F67",
secondary: "#ECF4E7",
accent: "#FFDA56",
neutral: "#2C2E27",
"base-100": "#FFFFFF",
info: "#3ABFF8",
success: "#36D399",
warning: "#FBBD23",
error: "#F87272",
},
},
"light",
"dark",
"cupcake",
"bumblebee",
"emerald",
"corporate",
"synthwave",
"retro",
"cyberpunk",
"valentine",
"halloween",
"garden",
"forest",
"aqua",
"lofi",
"pastel",
"fantasy",
"wireframe",
"black",
"luxury",
"dracula",
"cmyk",
"autumn",
"business",
"acid",
"lemonade",
"night",
"coffee",
"winter",
],
},
variants: { variants: {
extend: {}, extend: {},
}, },