2.2.1 - Misc improvements and cleanup
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m3s
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m3s
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "chronus",
|
"name": "chronus",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "2.2.0",
|
"version": "2.2.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
|
|||||||
31
pnpm-lock.yaml
generated
31
pnpm-lock.yaml
generated
@@ -1711,8 +1711,8 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
decode-named-character-reference@1.2.0:
|
decode-named-character-reference@1.3.0:
|
||||||
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
|
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
|
||||||
|
|
||||||
decompress-response@6.0.0:
|
decompress-response@6.0.0:
|
||||||
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
||||||
@@ -2366,6 +2366,7 @@ packages:
|
|||||||
|
|
||||||
libsql@0.5.22:
|
libsql@0.5.22:
|
||||||
resolution: {integrity: sha512-NscWthMQt7fpU8lqd7LXMvT9pi+KhhmTHAJWUB/Lj6MWa0MKFv0F2V4C6WKKpjCVZl0VwcDz4nOI3CyaT1DDiA==}
|
resolution: {integrity: sha512-NscWthMQt7fpU8lqd7LXMvT9pi+KhhmTHAJWUB/Lj6MWa0MKFv0F2V4C6WKKpjCVZl0VwcDz4nOI3CyaT1DDiA==}
|
||||||
|
cpu: [x64, arm64, wasm32, arm]
|
||||||
os: [darwin, linux, win32]
|
os: [darwin, linux, win32]
|
||||||
|
|
||||||
lightningcss-android-arm64@1.30.2:
|
lightningcss-android-arm64@1.30.2:
|
||||||
@@ -2672,8 +2673,8 @@ packages:
|
|||||||
nlcst-to-string@4.0.0:
|
nlcst-to-string@4.0.0:
|
||||||
resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==}
|
resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==}
|
||||||
|
|
||||||
node-abi@3.86.0:
|
node-abi@3.87.0:
|
||||||
resolution: {integrity: sha512-sn9Et4N3ynsetj3spsZR729DVlGH6iBG4RiDMV7HEp3guyOW6W3S0unGpLDxT50mXortGUMax/ykUNQXdqc/Xg==}
|
resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
node-domexception@1.0.0:
|
node-domexception@1.0.0:
|
||||||
@@ -3157,8 +3158,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
tar@7.5.3:
|
tar@7.5.4:
|
||||||
resolution: {integrity: sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==}
|
resolution: {integrity: sha512-AN04xbWGrSTDmVwlI4/GTlIIwMFk/XEv7uL8aa57zuvRy6s4hdBed+lVq2fAZ89XDa7Us3ANXcE3Tvqvja1kTA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
tiny-inflate@1.0.3:
|
tiny-inflate@1.0.3:
|
||||||
@@ -4246,7 +4247,7 @@ snapshots:
|
|||||||
local-pkg: 1.1.2
|
local-pkg: 1.1.2
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
svgo: 3.3.2
|
svgo: 3.3.2
|
||||||
tar: 7.5.3
|
tar: 7.5.4
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -5522,7 +5523,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
decode-named-character-reference@1.2.0:
|
decode-named-character-reference@1.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
character-entities: 2.0.2
|
character-entities: 2.0.2
|
||||||
|
|
||||||
@@ -6268,7 +6269,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/mdast': 4.0.4
|
'@types/mdast': 4.0.4
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
decode-named-character-reference: 1.2.0
|
decode-named-character-reference: 1.3.0
|
||||||
devlop: 1.1.0
|
devlop: 1.1.0
|
||||||
mdast-util-to-string: 4.0.0
|
mdast-util-to-string: 4.0.0
|
||||||
micromark: 4.0.2
|
micromark: 4.0.2
|
||||||
@@ -6381,7 +6382,7 @@ snapshots:
|
|||||||
|
|
||||||
micromark-core-commonmark@2.0.3:
|
micromark-core-commonmark@2.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
decode-named-character-reference: 1.2.0
|
decode-named-character-reference: 1.3.0
|
||||||
devlop: 1.1.0
|
devlop: 1.1.0
|
||||||
micromark-factory-destination: 2.0.1
|
micromark-factory-destination: 2.0.1
|
||||||
micromark-factory-label: 2.0.1
|
micromark-factory-label: 2.0.1
|
||||||
@@ -6514,7 +6515,7 @@ snapshots:
|
|||||||
|
|
||||||
micromark-util-decode-string@2.0.1:
|
micromark-util-decode-string@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
decode-named-character-reference: 1.2.0
|
decode-named-character-reference: 1.3.0
|
||||||
micromark-util-character: 2.1.1
|
micromark-util-character: 2.1.1
|
||||||
micromark-util-decode-numeric-character-reference: 2.0.2
|
micromark-util-decode-numeric-character-reference: 2.0.2
|
||||||
micromark-util-symbol: 2.0.1
|
micromark-util-symbol: 2.0.1
|
||||||
@@ -6552,7 +6553,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/debug': 4.1.12
|
'@types/debug': 4.1.12
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
decode-named-character-reference: 1.2.0
|
decode-named-character-reference: 1.3.0
|
||||||
devlop: 1.1.0
|
devlop: 1.1.0
|
||||||
micromark-core-commonmark: 2.0.3
|
micromark-core-commonmark: 2.0.3
|
||||||
micromark-factory-space: 2.0.1
|
micromark-factory-space: 2.0.1
|
||||||
@@ -6623,7 +6624,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/nlcst': 2.0.3
|
'@types/nlcst': 2.0.3
|
||||||
|
|
||||||
node-abi@3.86.0:
|
node-abi@3.87.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.7.3
|
semver: 7.7.3
|
||||||
optional: true
|
optional: true
|
||||||
@@ -6809,7 +6810,7 @@ snapshots:
|
|||||||
minimist: 1.2.8
|
minimist: 1.2.8
|
||||||
mkdirp-classic: 0.5.3
|
mkdirp-classic: 0.5.3
|
||||||
napi-build-utils: 2.0.0
|
napi-build-utils: 2.0.0
|
||||||
node-abi: 3.86.0
|
node-abi: 3.87.0
|
||||||
pump: 3.0.3
|
pump: 3.0.3
|
||||||
rc: 1.2.8
|
rc: 1.2.8
|
||||||
simple-get: 4.0.1
|
simple-get: 4.0.1
|
||||||
@@ -7240,7 +7241,7 @@ snapshots:
|
|||||||
readable-stream: 3.6.2
|
readable-stream: 3.6.2
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
tar@7.5.3:
|
tar@7.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@isaacs/fs-minipass': 4.0.1
|
'@isaacs/fs-minipass': 4.0.1
|
||||||
chownr: 3.0.0
|
chownr: 3.0.0
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div style="position: relative; height: 100%; width: 100%;">
|
<div style="position: relative; height: 100%; width: 100%">
|
||||||
<Bar :data="chartData" :options="chartOptions" />
|
<Bar :data="chartData" :options="chartOptions" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from "vue";
|
||||||
import { Bar } from 'vue-chartjs';
|
import { Bar } from "vue-chartjs";
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
BarElement,
|
BarElement,
|
||||||
@@ -14,10 +14,18 @@ import {
|
|||||||
LinearScale,
|
LinearScale,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
BarController
|
BarController,
|
||||||
} from 'chart.js';
|
type ChartOptions,
|
||||||
|
} from "chart.js";
|
||||||
|
|
||||||
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend, BarController);
|
ChartJS.register(
|
||||||
|
BarElement,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
BarController,
|
||||||
|
);
|
||||||
|
|
||||||
interface ClientData {
|
interface ClientData {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -29,57 +37,61 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const chartData = computed(() => ({
|
const chartData = computed(() => ({
|
||||||
labels: props.clients.map(c => c.name),
|
labels: props.clients.map((c) => c.name),
|
||||||
datasets: [{
|
datasets: [
|
||||||
label: 'Time Tracked',
|
{
|
||||||
data: props.clients.map(c => c.totalTime / (1000 * 60)), // Convert to minutes
|
label: "Time Tracked",
|
||||||
backgroundColor: '#6366f1',
|
data: props.clients.map((c) => c.totalTime / (1000 * 60)), // Convert to minutes
|
||||||
borderColor: '#4f46e5',
|
backgroundColor: "#6366f1",
|
||||||
borderWidth: 1,
|
borderColor: "#4f46e5",
|
||||||
}]
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const chartOptions = {
|
const chartOptions: ChartOptions<"bar"> = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
scales: {
|
scales: {
|
||||||
y: {
|
y: {
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
ticks: {
|
ticks: {
|
||||||
color: '#e2e8f0',
|
color: "#e2e8f0",
|
||||||
callback: function(value: number) {
|
callback: function (value: string | number) {
|
||||||
const hours = Math.floor(value / 60);
|
const numValue =
|
||||||
const mins = value % 60;
|
typeof value === "string" ? parseFloat(value) : value;
|
||||||
|
const hours = Math.floor(numValue / 60);
|
||||||
|
const mins = Math.round(numValue % 60);
|
||||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
color: '#334155'
|
color: "#334155",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
ticks: {
|
ticks: {
|
||||||
color: '#e2e8f0'
|
color: "#e2e8f0",
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
display: false
|
display: false,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
display: false
|
display: false,
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: function(context: any) {
|
label: function (context) {
|
||||||
const minutes = Math.round(context.raw);
|
const minutes = Math.round(context.raw as number);
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
const mins = minutes % 60;
|
const mins = minutes % 60;
|
||||||
return ` ${hours}h ${mins}m`;
|
return ` ${hours}h ${mins}m`;
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const isSubmitting = ref(false);
|
|||||||
const error = ref("");
|
const error = ref("");
|
||||||
const success = ref(false);
|
const success = ref(false);
|
||||||
|
|
||||||
// Set default dates to today
|
|
||||||
const today = new Date().toISOString().split("T")[0];
|
const today = new Date().toISOString().split("T")[0];
|
||||||
startDate.value = today;
|
startDate.value = today;
|
||||||
endDate.value = today;
|
endDate.value = today;
|
||||||
@@ -114,12 +113,10 @@ async function submitManualEntry() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
success.value = true;
|
success.value = true;
|
||||||
|
|
||||||
// Calculate duration for success message
|
|
||||||
const start = new Date(startDateTime);
|
const start = new Date(startDateTime);
|
||||||
const end = new Date(endDateTime);
|
const end = new Date(endDateTime);
|
||||||
const duration = formatDuration(start, end);
|
const duration = formatDuration(start, end);
|
||||||
|
|
||||||
// Reset form
|
|
||||||
description.value = "";
|
description.value = "";
|
||||||
selectedClientId.value = "";
|
selectedClientId.value = "";
|
||||||
selectedCategoryId.value = "";
|
selectedCategoryId.value = "";
|
||||||
@@ -129,7 +126,6 @@ async function submitManualEntry() {
|
|||||||
startTime.value = "";
|
startTime.value = "";
|
||||||
endTime.value = "";
|
endTime.value = "";
|
||||||
|
|
||||||
// Emit event and reload after a short delay
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emit("entryCreated");
|
emit("entryCreated");
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
|
|||||||
@@ -58,9 +58,11 @@ const chartOptions: ChartOptions<"bar"> = {
|
|||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
ticks: {
|
ticks: {
|
||||||
color: "#e2e8f0",
|
color: "#e2e8f0",
|
||||||
callback: function (value: any) {
|
callback: function (value: string | number) {
|
||||||
const hours = Math.floor(value / 60);
|
const numValue =
|
||||||
const mins = value % 60;
|
typeof value === "string" ? parseFloat(value) : value;
|
||||||
|
const hours = Math.floor(numValue / 60);
|
||||||
|
const mins = Math.round(numValue % 60);
|
||||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -83,8 +85,8 @@ const chartOptions: ChartOptions<"bar"> = {
|
|||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: function (context: any) {
|
label: function (context) {
|
||||||
const minutes = Math.round(context.raw);
|
const minutes = Math.round(context.raw as number);
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
const mins = minutes % 60;
|
const mins = minutes % 60;
|
||||||
return ` ${hours}h ${mins}m`;
|
return ` ${hours}h ${mins}m`;
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ function formatTime(ms: number) {
|
|||||||
|
|
||||||
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
|
||||||
// Calculate rounded version
|
|
||||||
const totalMinutes = Math.round(ms / 1000 / 60);
|
const totalMinutes = Math.round(ms / 1000 / 60);
|
||||||
const roundedHours = Math.floor(totalMinutes / 60);
|
const roundedHours = Math.floor(totalMinutes / 60);
|
||||||
const roundedMinutes = totalMinutes % 60;
|
const roundedMinutes = totalMinutes % 60;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from "vue";
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from "@iconify/vue";
|
||||||
import { startAuthentication } from '@simplewebauthn/browser';
|
import { startAuthentication } from "@simplewebauthn/browser";
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
@@ -11,45 +11,41 @@ async function handlePasskeyLogin() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Get options from server
|
const resp = await fetch("/api/auth/passkey/login/start");
|
||||||
const resp = await fetch('/api/auth/passkey/login/start');
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
throw new Error('Failed to start passkey login');
|
throw new Error("Failed to start passkey login");
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = await resp.json();
|
const options = await resp.json();
|
||||||
|
|
||||||
// 2. Browser handles interaction
|
|
||||||
let asseResp;
|
let asseResp;
|
||||||
try {
|
try {
|
||||||
asseResp = await startAuthentication(options);
|
asseResp = await startAuthentication({ optionsJSON: options });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as any).name === 'NotAllowedError') {
|
if ((err as any).name === "NotAllowedError") {
|
||||||
// User cancelled or timed out
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error(err);
|
console.error(err);
|
||||||
error.value = 'Failed to authenticate with passkey';
|
error.value = "Failed to authenticate with passkey";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Verify with server
|
const verificationResp = await fetch("/api/auth/passkey/login/finish", {
|
||||||
const verificationResp = await fetch('/api/auth/passkey/login/finish', {
|
method: "POST",
|
||||||
method: 'POST',
|
headers: { "Content-Type": "application/json" },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(asseResp),
|
body: JSON.stringify(asseResp),
|
||||||
});
|
});
|
||||||
|
|
||||||
const verificationJSON = await verificationResp.json();
|
const verificationJSON = await verificationResp.json();
|
||||||
if (verificationJSON.verified) {
|
if (verificationJSON.verified) {
|
||||||
window.location.href = '/dashboard';
|
window.location.href = "/dashboard";
|
||||||
} else {
|
} else {
|
||||||
error.value = 'Login failed. Please try again.';
|
error.value = "Login failed. Please try again.";
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error during passkey login:', err);
|
console.error("Error during passkey login:", err);
|
||||||
error.value = 'An error occurred during login';
|
error.value = "An error occurred during login";
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ async function createToken() {
|
|||||||
|
|
||||||
const { token, ...tokenMeta } = data;
|
const { token, ...tokenMeta } = data;
|
||||||
|
|
||||||
// Add to beginning of list
|
|
||||||
tokens.value.unshift({
|
tokens.value.unshift({
|
||||||
id: tokenMeta.id,
|
id: tokenMeta.id,
|
||||||
name: tokenMeta.name,
|
name: tokenMeta.name,
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ function formatDate(dateString: string | null) {
|
|||||||
async function registerPasskey() {
|
async function registerPasskey() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
// 1. Get options from server
|
|
||||||
const resp = await fetch("/api/auth/passkey/register/start");
|
const resp = await fetch("/api/auth/passkey/register/start");
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
@@ -40,13 +39,11 @@ async function registerPasskey() {
|
|||||||
|
|
||||||
const options = await resp.json();
|
const options = await resp.json();
|
||||||
|
|
||||||
// 2. Browser handles interaction
|
|
||||||
let attResp;
|
let attResp;
|
||||||
try {
|
try {
|
||||||
attResp = await startRegistration(options);
|
attResp = await startRegistration({ optionsJSON: options });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as any).name === "NotAllowedError") {
|
if ((error as any).name === "NotAllowedError") {
|
||||||
// User cancelled or timed out
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -54,7 +51,6 @@ async function registerPasskey() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Verify with server
|
|
||||||
const verificationResp = await fetch("/api/auth/passkey/register/finish", {
|
const verificationResp = await fetch("/api/auth/passkey/register/finish", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -63,8 +59,6 @@ async function registerPasskey() {
|
|||||||
|
|
||||||
const verificationJSON = await verificationResp.json();
|
const verificationJSON = await verificationResp.json();
|
||||||
if (verificationJSON.verified) {
|
if (verificationJSON.verified) {
|
||||||
// Reload to show the new passkey since the API doesn't return the created object
|
|
||||||
// Ideally we would return the object and append it to 'passkeys' to avoid reload
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} else {
|
} else {
|
||||||
alert("Passkey registration failed");
|
alert("Passkey registration failed");
|
||||||
@@ -88,7 +82,6 @@ async function deletePasskey(id: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Optimistically remove from list
|
|
||||||
passkeys.value = passkeys.value.filter((pk) => pk.id !== id);
|
passkeys.value = passkeys.value.filter((pk) => pk.id !== id);
|
||||||
} else {
|
} else {
|
||||||
alert("Failed to delete passkey");
|
alert("Failed to delete passkey");
|
||||||
|
|||||||
@@ -37,12 +37,10 @@ async function changePassword() {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
message.value = { type: 'success', text: 'Password changed successfully!' };
|
message.value = { type: 'success', text: 'Password changed successfully!' };
|
||||||
// Reset form
|
|
||||||
currentPassword.value = '';
|
currentPassword.value = '';
|
||||||
newPassword.value = '';
|
newPassword.value = '';
|
||||||
confirmPassword.value = '';
|
confirmPassword.value = '';
|
||||||
|
|
||||||
// Hide success message after 3 seconds
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
message.value = null;
|
message.value = null;
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from "vue";
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from "@iconify/vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
user: {
|
user: {
|
||||||
@@ -12,33 +12,38 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const name = ref(props.user.name);
|
const name = ref(props.user.name);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const message = ref<{ type: 'success' | 'error'; text: string } | null>(null);
|
const message = ref<{ type: "success" | "error"; text: string } | null>(null);
|
||||||
|
|
||||||
async function updateProfile() {
|
async function updateProfile() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
message.value = null;
|
message.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/user/update-profile', {
|
const response = await fetch("/api/user/update-profile", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ name: name.value }),
|
body: JSON.stringify({ name: name.value }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
message.value = { type: 'success', text: 'Profile updated successfully!' };
|
message.value = {
|
||||||
// Hide success message after 3 seconds
|
type: "success",
|
||||||
|
text: "Profile updated successfully!",
|
||||||
|
};
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
message.value = null;
|
message.value = null;
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} else {
|
} else {
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
message.value = { type: 'error', text: data.error || 'Failed to update profile' };
|
message.value = {
|
||||||
|
type: "error",
|
||||||
|
text: data.error || "Failed to update profile",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = { type: 'error', text: 'An error occurred' };
|
message.value = { type: "error", text: "An error occurred" };
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -48,8 +53,21 @@ async function updateProfile() {
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- Success/Error Message Display -->
|
<!-- Success/Error Message Display -->
|
||||||
<div v-if="message" :class="['alert mb-6', message.type === 'success' ? 'alert-success' : 'alert-error']">
|
<div
|
||||||
<Icon :icon="message.type === 'success' ? 'heroicons:check-circle' : 'heroicons:exclamation-circle'" class="w-6 h-6 shrink-0" />
|
v-if="message"
|
||||||
|
:class="[
|
||||||
|
'alert mb-6',
|
||||||
|
message.type === 'success' ? 'alert-success' : 'alert-error',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:icon="
|
||||||
|
message.type === 'success'
|
||||||
|
? 'heroicons:check-circle'
|
||||||
|
: 'heroicons:exclamation-circle'
|
||||||
|
"
|
||||||
|
class="w-6 h-6 shrink-0"
|
||||||
|
/>
|
||||||
<span>{{ message.text }}</span>
|
<span>{{ message.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -63,7 +81,9 @@ async function updateProfile() {
|
|||||||
<form @submit.prevent="updateProfile" class="space-y-5">
|
<form @submit.prevent="updateProfile" class="space-y-5">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<label class="label pb-2">
|
||||||
<span class="label-text font-medium text-sm sm:text-base">Full Name</span>
|
<span class="label-text font-medium text-sm sm:text-base"
|
||||||
|
>Full Name</span
|
||||||
|
>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -76,7 +96,9 @@ async function updateProfile() {
|
|||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<label class="label pb-2">
|
||||||
<span class="label-text font-medium text-sm sm:text-base">Email</span>
|
<span class="label-text font-medium text-sm sm:text-base"
|
||||||
|
>Email</span
|
||||||
|
>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@@ -85,13 +107,23 @@ async function updateProfile() {
|
|||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
<div class="label pt-2">
|
<div class="label pt-2">
|
||||||
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Email cannot be changed</span>
|
<span
|
||||||
|
class="label-text-alt text-base-content/60 text-xs sm:text-sm"
|
||||||
|
>Email cannot be changed</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end pt-4">
|
<div class="flex justify-end pt-4">
|
||||||
<button type="submit" class="btn btn-primary w-full sm:w-auto" :disabled="loading">
|
<button
|
||||||
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
|
type="submit"
|
||||||
|
class="btn btn-primary w-full sm:w-auto"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="loading"
|
||||||
|
class="loading loading-spinner loading-sm"
|
||||||
|
></span>
|
||||||
<Icon v-else icon="heroicons:check" class="w-5 h-5" />
|
<Icon v-else icon="heroicons:check" class="w-5 h-5" />
|
||||||
Save Changes
|
Save Changes
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import * as schema from "./schema";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
|
||||||
// Define the database type based on the schema
|
|
||||||
type Database = ReturnType<typeof drizzle<typeof schema>>;
|
type Database = ReturnType<typeof drizzle<typeof schema>>;
|
||||||
|
|
||||||
let _db: Database | null = null;
|
let _db: Database | null = null;
|
||||||
|
|||||||
@@ -270,19 +270,19 @@ export const invoices = sqliteTable(
|
|||||||
organizationId: text("organization_id").notNull(),
|
organizationId: text("organization_id").notNull(),
|
||||||
clientId: text("client_id").notNull(),
|
clientId: text("client_id").notNull(),
|
||||||
number: text("number").notNull(),
|
number: text("number").notNull(),
|
||||||
type: text("type").notNull().default("invoice"), // 'invoice' or 'quote'
|
type: text("type").notNull().default("invoice"),
|
||||||
status: text("status").notNull().default("draft"), // 'draft', 'sent', 'paid', 'void', 'accepted', 'declined'
|
status: text("status").notNull().default("draft"),
|
||||||
issueDate: integer("issue_date", { mode: "timestamp" }).notNull(),
|
issueDate: integer("issue_date", { mode: "timestamp" }).notNull(),
|
||||||
dueDate: integer("due_date", { mode: "timestamp" }).notNull(),
|
dueDate: integer("due_date", { mode: "timestamp" }).notNull(),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
currency: text("currency").default("USD").notNull(),
|
currency: text("currency").default("USD").notNull(),
|
||||||
subtotal: integer("subtotal").notNull().default(0), // in cents
|
subtotal: integer("subtotal").notNull().default(0),
|
||||||
discountValue: real("discount_value").default(0),
|
discountValue: real("discount_value").default(0),
|
||||||
discountType: text("discount_type").default("percentage"), // 'percentage' or 'fixed'
|
discountType: text("discount_type").default("percentage"),
|
||||||
discountAmount: integer("discount_amount").default(0), // in cents
|
discountAmount: integer("discount_amount").default(0),
|
||||||
taxRate: real("tax_rate").default(0), // percentage
|
taxRate: real("tax_rate").default(0),
|
||||||
taxAmount: integer("tax_amount").notNull().default(0), // in cents
|
taxAmount: integer("tax_amount").notNull().default(0),
|
||||||
total: integer("total").notNull().default(0), // in cents
|
total: integer("total").notNull().default(0),
|
||||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
() => new Date(),
|
() => new Date(),
|
||||||
),
|
),
|
||||||
@@ -312,8 +312,8 @@ export const invoiceItems = sqliteTable(
|
|||||||
invoiceId: text("invoice_id").notNull(),
|
invoiceId: text("invoice_id").notNull(),
|
||||||
description: text("description").notNull(),
|
description: text("description").notNull(),
|
||||||
quantity: real("quantity").notNull().default(1),
|
quantity: real("quantity").notNull().default(1),
|
||||||
unitPrice: integer("unit_price").notNull().default(0), // in cents
|
unitPrice: integer("unit_price").notNull().default(0),
|
||||||
amount: integer("amount").notNull().default(0), // in cents
|
amount: integer("amount").notNull().default(0),
|
||||||
},
|
},
|
||||||
(table: any) => ({
|
(table: any) => ({
|
||||||
invoiceFk: foreignKey({
|
invoiceFk: foreignKey({
|
||||||
@@ -327,13 +327,13 @@ export const invoiceItems = sqliteTable(
|
|||||||
export const passkeys = sqliteTable(
|
export const passkeys = sqliteTable(
|
||||||
"passkeys",
|
"passkeys",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(), // The Credential ID
|
id: text("id").primaryKey(),
|
||||||
userId: text("user_id").notNull(),
|
userId: text("user_id").notNull(),
|
||||||
publicKey: text("public_key").notNull(), // Base64 encoded public key
|
publicKey: text("public_key").notNull(),
|
||||||
counter: integer("counter").notNull(),
|
counter: integer("counter").notNull(),
|
||||||
deviceType: text("device_type").notNull(), // 'singleDevice' or 'multiDevice'
|
deviceType: text("device_type").notNull(),
|
||||||
backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
|
backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
|
||||||
transports: text("transports"), // JSON stringified array
|
transports: text("transports"),
|
||||||
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
|
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
|
||||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
() => new Date(),
|
() => new Date(),
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ if (!user) {
|
|||||||
return Astro.redirect('/login');
|
return Astro.redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user's team memberships
|
|
||||||
const userMemberships = await db.select({
|
const userMemberships = await db.select({
|
||||||
membership: members,
|
membership: members,
|
||||||
organization: organizations,
|
organization: organizations,
|
||||||
@@ -28,7 +27,6 @@ const userMemberships = await db.select({
|
|||||||
.where(eq(members.userId, user.id))
|
.where(eq(members.userId, user.id))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
// Get current team from cookie or use first membership
|
|
||||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value || userMemberships[0]?.organization.id;
|
const currentTeamId = Astro.cookies.get('currentTeamId')?.value || userMemberships[0]?.organization.id;
|
||||||
const currentTeam = userMemberships.find(m => m.organization.id === currentTeamId);
|
const currentTeam = userMemberships.find(m => m.organization.id === currentTeamId);
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export async function validateApiToken(token: string) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last used at
|
|
||||||
await db
|
await db
|
||||||
.update(apiTokens)
|
.update(apiTokens)
|
||||||
.set({ lastUsedAt: new Date() })
|
.set({ lastUsedAt: new Date() })
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
* @returns Formatted string like "01:23:45 (1h 24m)" or "00:05:23 (5m)"
|
* @returns Formatted string like "01:23:45 (1h 24m)" or "00:05:23 (5m)"
|
||||||
*/
|
*/
|
||||||
export function formatDuration(ms: number): string {
|
export function formatDuration(ms: number): string {
|
||||||
// Calculate rounded version for easy reading
|
|
||||||
const totalMinutes = Math.round(ms / 1000 / 60);
|
const totalMinutes = Math.round(ms / 1000 / 60);
|
||||||
const hours = Math.floor(totalMinutes / 60);
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
const minutes = totalMinutes % 60;
|
const minutes = totalMinutes % 60;
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user's existing passkeys to prevent registering the same authenticator twice
|
|
||||||
const userPasskeys = await db.query.passkeys.findMany({
|
const userPasskeys = await db.query.passkeys.findMany({
|
||||||
where: eq(passkeys.userId, user.id),
|
where: eq(passkeys.userId, user.id),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
|
|||||||
return new Response("Invoice ID required", { status: 400 });
|
return new Response("Invoice ID required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch invoice to verify existence
|
|
||||||
const invoice = await db
|
const invoice = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify membership
|
|
||||||
const membership = await db
|
const membership = await db
|
||||||
.select()
|
.select()
|
||||||
.from(members)
|
.from(members)
|
||||||
@@ -48,7 +46,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate next invoice number
|
|
||||||
const lastInvoice = await db
|
const lastInvoice = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
@@ -74,11 +71,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert quote to invoice:
|
|
||||||
// 1. Change type to 'invoice'
|
|
||||||
// 2. Set status to 'draft' (so user can review before sending)
|
|
||||||
// 3. Update number to next invoice sequence
|
|
||||||
// 4. Update issue date to today
|
|
||||||
await db
|
await db
|
||||||
.update(invoices)
|
.update(invoices)
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
|
|||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch invoice with related data
|
|
||||||
const invoiceResult = await db
|
const invoiceResult = await db
|
||||||
.select({
|
.select({
|
||||||
invoice: invoices,
|
invoice: invoices,
|
||||||
@@ -39,7 +38,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
|
|||||||
|
|
||||||
const { invoice, client, organization } = invoiceResult;
|
const { invoice, client, organization } = invoiceResult;
|
||||||
|
|
||||||
// Verify access
|
|
||||||
const membership = await db
|
const membership = await db
|
||||||
.select()
|
.select()
|
||||||
.from(members)
|
.from(members)
|
||||||
@@ -55,7 +53,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
|
|||||||
return new Response("Forbidden", { status: 403 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch items
|
|
||||||
const items = await db
|
const items = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoiceItems)
|
.from(invoiceItems)
|
||||||
@@ -66,8 +63,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
|
|||||||
return new Response("Client not found", { status: 404 });
|
return new Response("Client not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate PDF using Vue PDF
|
|
||||||
// Suppress verbose logging from PDF renderer
|
|
||||||
const originalConsoleLog = console.log;
|
const originalConsoleLog = console.log;
|
||||||
const originalConsoleWarn = console.warn;
|
const originalConsoleWarn = console.warn;
|
||||||
console.log = () => {};
|
console.log = () => {};
|
||||||
@@ -83,7 +78,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
|
|||||||
|
|
||||||
const stream = await renderToStream(pdfDocument);
|
const stream = await renderToStream(pdfDocument);
|
||||||
|
|
||||||
// Restore console.log
|
|
||||||
console.log = originalConsoleLog;
|
console.log = originalConsoleLog;
|
||||||
console.warn = originalConsoleWarn;
|
console.warn = originalConsoleWarn;
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export const POST: APIRoute = async ({
|
|||||||
const quantity = parseFloat(quantityStr);
|
const quantity = parseFloat(quantityStr);
|
||||||
const unitPriceMajor = parseFloat(unitPriceStr);
|
const unitPriceMajor = parseFloat(unitPriceStr);
|
||||||
|
|
||||||
// Convert to cents
|
|
||||||
const unitPrice = Math.round(unitPriceMajor * 100);
|
const unitPrice = Math.round(unitPriceMajor * 100);
|
||||||
const amount = Math.round(quantity * unitPrice);
|
const amount = Math.round(quantity * unitPrice);
|
||||||
|
|
||||||
@@ -77,7 +76,6 @@ export const POST: APIRoute = async ({
|
|||||||
amount,
|
amount,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update invoice totals
|
|
||||||
await recalculateInvoiceTotals(invoiceId);
|
await recalculateInvoiceTotals(invoiceId);
|
||||||
|
|
||||||
return redirect(`/dashboard/invoices/${invoiceId}`);
|
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Invoice ID required", { status: 400 });
|
return new Response("Invoice ID required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch invoice to verify existence and check status
|
|
||||||
const invoice = await db
|
const invoice = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Invoice not found", { status: 404 });
|
return new Response("Invoice not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify membership
|
|
||||||
const membership = await db
|
const membership = await db
|
||||||
.select()
|
.select()
|
||||||
.from(members)
|
.from(members)
|
||||||
@@ -47,7 +45,6 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow editing if draft
|
|
||||||
if (invoice.status !== "draft") {
|
if (invoice.status !== "draft") {
|
||||||
return new Response("Cannot edit a finalized invoice", { status: 400 });
|
return new Response("Cannot edit a finalized invoice", { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -59,7 +56,6 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Item ID required", { status: 400 });
|
return new Response("Item ID required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify item belongs to invoice
|
|
||||||
const item = await db
|
const item = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoiceItems)
|
.from(invoiceItems)
|
||||||
@@ -73,7 +69,6 @@ export const POST: APIRoute = async ({
|
|||||||
try {
|
try {
|
||||||
await db.delete(invoiceItems).where(eq(invoiceItems.id, itemId));
|
await db.delete(invoiceItems).where(eq(invoiceItems.id, itemId));
|
||||||
|
|
||||||
// Update invoice totals
|
|
||||||
await recalculateInvoiceTotals(invoiceId);
|
await recalculateInvoiceTotals(invoiceId);
|
||||||
|
|
||||||
return redirect(`/dashboard/invoices/${invoiceId}`);
|
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Invalid status", { status: 400 });
|
return new Response("Invalid status", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch invoice to verify existence and check ownership
|
|
||||||
const invoice = await db
|
const invoice = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
@@ -46,7 +45,6 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Invoice not found", { status: 404 });
|
return new Response("Invoice not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify membership
|
|
||||||
const membership = await db
|
const membership = await db
|
||||||
.select()
|
.select()
|
||||||
.from(members)
|
.from(members)
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Invoice ID required", { status: 400 });
|
return new Response("Invoice ID required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch invoice to verify existence
|
|
||||||
const invoice = await db
|
const invoice = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Invoice not found", { status: 404 });
|
return new Response("Invoice not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify membership
|
|
||||||
const membership = await db
|
const membership = await db
|
||||||
.select()
|
.select()
|
||||||
.from(members)
|
.from(members)
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ const isDraft = invoice.status === 'draft';
|
|||||||
<span class="text-base-content/60">Subtotal</span>
|
<span class="text-base-content/60">Subtotal</span>
|
||||||
<span class="font-medium">{formatCurrency(invoice.subtotal)}</span>
|
<span class="font-medium">{formatCurrency(invoice.subtotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
{(invoice.discountAmount > 0) && (
|
{(invoice.discountAmount && invoice.discountAmount > 0) && (
|
||||||
<div class="flex justify-between text-sm">
|
<div class="flex justify-between text-sm">
|
||||||
<span class="text-base-content/60">
|
<span class="text-base-content/60">
|
||||||
Discount
|
Discount
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export async function recalculateInvoiceTotals(invoiceId: string) {
|
|||||||
.all();
|
.all();
|
||||||
|
|
||||||
// Calculate totals
|
// Calculate totals
|
||||||
// Note: amounts are in cents
|
|
||||||
const subtotal = items.reduce((acc, item) => acc + item.amount, 0);
|
const subtotal = items.reduce((acc, item) => acc + item.amount, 0);
|
||||||
|
|
||||||
// Calculate discount
|
// Calculate discount
|
||||||
|
|||||||
Reference in New Issue
Block a user