Enhance receipt management by adding a Description field to receipts, improving user input validation and error handling in the server actions. Update UI components to support the new Description field, ensuring a more comprehensive receipt creation and editing experience.
This commit is contained in:
@@ -10,11 +10,16 @@ const COLLECTION = "Stackq_Receipts";
|
|||||||
const TYPE_OPTIONS = ["Purchase", "Gas"] as const;
|
const TYPE_OPTIONS = ["Purchase", "Gas"] as const;
|
||||||
|
|
||||||
export const load = async ({ locals }: Parameters<PageServerLoad>[0]) => {
|
export const load = async ({ locals }: Parameters<PageServerLoad>[0]) => {
|
||||||
|
const user = locals.user as { id: string; Admin?: boolean } | null;
|
||||||
|
if (!user?.id) return { receipts: [] as StackqReceipt[] };
|
||||||
|
|
||||||
|
const isAdmin = user.Admin === true;
|
||||||
const list = await locals.pb
|
const list = await locals.pb
|
||||||
.collection(COLLECTION)
|
.collection(COLLECTION)
|
||||||
.getFullList<StackqReceipt>({
|
.getFullList<StackqReceipt>({
|
||||||
sort: "-created",
|
sort: "-created",
|
||||||
expand: "User",
|
expand: "User",
|
||||||
|
...(isAdmin ? {} : { filter: `User = "${user.id}"` }),
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(COLLECTION, err);
|
console.error(COLLECTION, err);
|
||||||
@@ -31,11 +36,15 @@ export const actions = {
|
|||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const Type = (form.get("Type") as string)?.trim() || "Purchase";
|
const Type = (form.get("Type") as string)?.trim() || "Purchase";
|
||||||
|
const Description = (form.get("Description") as string)?.trim() ?? "";
|
||||||
|
if (!Description) {
|
||||||
|
return fail(400, { error: "Description is required.", Type });
|
||||||
|
}
|
||||||
const imageFile = form.get("Image") as File | null;
|
const imageFile = form.get("Image") as File | null;
|
||||||
const hasImage = imageFile && imageFile.size > 0;
|
const hasImage = imageFile && imageFile.size > 0;
|
||||||
if (hasImage) {
|
if (hasImage) {
|
||||||
const imgError = validateImageUpload(imageFile);
|
const imgError = validateImageUpload(imageFile);
|
||||||
if (imgError) return fail(400, { error: imgError, Type });
|
if (imgError) return fail(400, { error: imgError, Type, Description });
|
||||||
}
|
}
|
||||||
|
|
||||||
let imageToSave: File | null = hasImage ? imageFile : null;
|
let imageToSave: File | null = hasImage ? imageFile : null;
|
||||||
@@ -46,6 +55,7 @@ export const actions = {
|
|||||||
return fail(400, {
|
return fail(400, {
|
||||||
error: "Could not convert HEIC image. Try saving as JPEG (iPhone: Settings → Camera → Formats) or use a different photo.",
|
error: "Could not convert HEIC image. Try saving as JPEG (iPhone: Settings → Camera → Formats) or use a different photo.",
|
||||||
Type,
|
Type,
|
||||||
|
Description,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,6 +63,7 @@ export const actions = {
|
|||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
Status: "New",
|
Status: "New",
|
||||||
Type: TYPE_OPTIONS.includes(Type as (typeof TYPE_OPTIONS)[number]) ? Type : "Purchase",
|
Type: TYPE_OPTIONS.includes(Type as (typeof TYPE_OPTIONS)[number]) ? Type : "Purchase",
|
||||||
|
Description,
|
||||||
User: user.id,
|
User: user.id,
|
||||||
};
|
};
|
||||||
if (imageToSave) body.Image = imageToSave;
|
if (imageToSave) body.Image = imageToSave;
|
||||||
@@ -64,7 +75,7 @@ export const actions = {
|
|||||||
err && typeof err === "object" && "message" in err
|
err && typeof err === "object" && "message" in err
|
||||||
? String((err as { message: string }).message)
|
? String((err as { message: string }).message)
|
||||||
: "Failed to create receipt.";
|
: "Failed to create receipt.";
|
||||||
return fail(500, { error: message, Type });
|
return fail(500, { error: message, Type, Description });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface StackqReceipt {
|
|||||||
id: string;
|
id: string;
|
||||||
Status: string;
|
Status: string;
|
||||||
Type: string;
|
Type: string;
|
||||||
|
Description?: string;
|
||||||
User: string;
|
User: string;
|
||||||
Image?: string;
|
Image?: string;
|
||||||
Data?: ReceiptDataShape | Record<string, unknown>;
|
Data?: ReceiptDataShape | Record<string, unknown>;
|
||||||
|
|||||||
@@ -9,11 +9,16 @@ const COLLECTION = "Stackq_Receipts";
|
|||||||
const TYPE_OPTIONS = ["Purchase", "Gas"] as const;
|
const TYPE_OPTIONS = ["Purchase", "Gas"] as const;
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
const user = locals.user as { id: string; Admin?: boolean } | null;
|
||||||
|
if (!user?.id) return { receipts: [] as StackqReceipt[] };
|
||||||
|
|
||||||
|
const isAdmin = user.Admin === true;
|
||||||
const list = await locals.pb
|
const list = await locals.pb
|
||||||
.collection(COLLECTION)
|
.collection(COLLECTION)
|
||||||
.getFullList<StackqReceipt>({
|
.getFullList<StackqReceipt>({
|
||||||
sort: "-created",
|
sort: "-created",
|
||||||
expand: "User",
|
expand: "User",
|
||||||
|
...(isAdmin ? {} : { filter: `User = "${user.id}"` }),
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(COLLECTION, err);
|
console.error(COLLECTION, err);
|
||||||
@@ -30,11 +35,15 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const Type = (form.get("Type") as string)?.trim() || "Purchase";
|
const Type = (form.get("Type") as string)?.trim() || "Purchase";
|
||||||
|
const Description = (form.get("Description") as string)?.trim() ?? "";
|
||||||
|
if (!Description) {
|
||||||
|
return fail(400, { error: "Description is required.", Type });
|
||||||
|
}
|
||||||
const imageFile = form.get("Image") as File | null;
|
const imageFile = form.get("Image") as File | null;
|
||||||
const hasImage = imageFile && imageFile.size > 0;
|
const hasImage = imageFile && imageFile.size > 0;
|
||||||
if (hasImage) {
|
if (hasImage) {
|
||||||
const imgError = validateImageUpload(imageFile);
|
const imgError = validateImageUpload(imageFile);
|
||||||
if (imgError) return fail(400, { error: imgError, Type });
|
if (imgError) return fail(400, { error: imgError, Type, Description });
|
||||||
}
|
}
|
||||||
|
|
||||||
let imageToSave: File | null = hasImage ? imageFile : null;
|
let imageToSave: File | null = hasImage ? imageFile : null;
|
||||||
@@ -45,6 +54,7 @@ export const actions: Actions = {
|
|||||||
return fail(400, {
|
return fail(400, {
|
||||||
error: "Could not convert HEIC image. Try saving as JPEG (iPhone: Settings → Camera → Formats) or use a different photo.",
|
error: "Could not convert HEIC image. Try saving as JPEG (iPhone: Settings → Camera → Formats) or use a different photo.",
|
||||||
Type,
|
Type,
|
||||||
|
Description,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,6 +62,7 @@ export const actions: Actions = {
|
|||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
Status: "New",
|
Status: "New",
|
||||||
Type: TYPE_OPTIONS.includes(Type as (typeof TYPE_OPTIONS)[number]) ? Type : "Purchase",
|
Type: TYPE_OPTIONS.includes(Type as (typeof TYPE_OPTIONS)[number]) ? Type : "Purchase",
|
||||||
|
Description,
|
||||||
User: user.id,
|
User: user.id,
|
||||||
};
|
};
|
||||||
if (imageToSave) body.Image = imageToSave;
|
if (imageToSave) body.Image = imageToSave;
|
||||||
@@ -63,7 +74,7 @@ export const actions: Actions = {
|
|||||||
err && typeof err === "object" && "message" in err
|
err && typeof err === "object" && "message" in err
|
||||||
? String((err as { message: string }).message)
|
? String((err as { message: string }).message)
|
||||||
: "Failed to create receipt.";
|
: "Failed to create receipt.";
|
||||||
return fail(500, { error: message, Type });
|
return fail(500, { error: message, Type, Description });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -128,6 +128,7 @@
|
|||||||
submitLabel="Create"
|
submitLabel="Create"
|
||||||
error={form?.error}
|
error={form?.error}
|
||||||
defaultType={form?.Type ?? "Purchase"}
|
defaultType={form?.Type ?? "Purchase"}
|
||||||
|
defaultDescription={form?.Description ?? ""}
|
||||||
onSuccess={onReceiptCreateSuccess}
|
onSuccess={onReceiptCreateSuccess}
|
||||||
/>
|
/>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import * as Select from "$lib/components/ui/select";
|
||||||
import { convertImageToWebP, isAcceptableImageFile, isHeicFile } from "$lib/convert-image-to-webp";
|
import { convertImageToWebP, isAcceptableImageFile, isHeicFile } from "$lib/convert-image-to-webp";
|
||||||
import { enhance } from "$app/forms";
|
import { enhance } from "$app/forms";
|
||||||
import Loader2Icon from "@lucide/svelte/icons/loader-2";
|
import Loader2Icon from "@lucide/svelte/icons/loader-2";
|
||||||
@@ -16,14 +18,30 @@
|
|||||||
error,
|
error,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
defaultType = "Purchase",
|
defaultType = "Purchase",
|
||||||
|
defaultDescription = "",
|
||||||
}: {
|
}: {
|
||||||
action: string;
|
action: string;
|
||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
defaultType?: string;
|
defaultType?: string;
|
||||||
|
defaultDescription?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
let typeValue = $state<string>("Purchase");
|
||||||
|
let typeInitialized = false;
|
||||||
|
$effect(() => {
|
||||||
|
if (!typeInitialized) {
|
||||||
|
typeValue = defaultType;
|
||||||
|
typeInitialized = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let descriptionValue = $state("");
|
||||||
|
$effect(() => {
|
||||||
|
if (error) {
|
||||||
|
descriptionValue = defaultDescription ?? "";
|
||||||
|
}
|
||||||
|
});
|
||||||
let imageFileName = $state<string | null>(null);
|
let imageFileName = $state<string | null>(null);
|
||||||
let imageConverting = $state(false);
|
let imageConverting = $state(false);
|
||||||
|
|
||||||
@@ -71,15 +89,30 @@
|
|||||||
<div class="min-h-0 flex-1 overflow-y-auto space-y-4">
|
<div class="min-h-0 flex-1 overflow-y-auto space-y-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="Type">Type</Label>
|
<Label for="Type">Type</Label>
|
||||||
<select
|
<input type="hidden" name="Type" value={typeValue} />
|
||||||
id="Type"
|
<Select.Root type="single" bind:value={typeValue}>
|
||||||
name="Type"
|
<Select.Trigger id="Type" class="w-full h-9">
|
||||||
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
|
<span>{typeValue}</span>
|
||||||
>
|
</Select.Trigger>
|
||||||
{#each TYPE_OPTIONS as o}
|
<Select.Content>
|
||||||
<option value={o} selected={defaultType === o}>{o}</option>
|
{#each TYPE_OPTIONS as o}
|
||||||
{/each}
|
<Select.Item value={o} label={o} />
|
||||||
</select>
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="Description">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="Description"
|
||||||
|
name="Description"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
bind:value={descriptionValue}
|
||||||
|
placeholder="materials purchased"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|||||||
@@ -14,10 +14,15 @@ const MAX_LINE_ITEMS = 500;
|
|||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
const user = locals.user as { id: string; Admin?: boolean } | null;
|
||||||
try {
|
try {
|
||||||
const record = await locals.pb
|
const record = await locals.pb
|
||||||
.collection(COLLECTION)
|
.collection(COLLECTION)
|
||||||
.getOne<StackqReceipt>(id, { expand: "User" });
|
.getOne<StackqReceipt>(id, { expand: "User" });
|
||||||
|
const isAdmin = user?.Admin === true;
|
||||||
|
if (!isAdmin && user?.id && record.User !== user.id) {
|
||||||
|
throw error(404, "Receipt not found");
|
||||||
|
}
|
||||||
return { receipt: record };
|
return { receipt: record };
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const is404 =
|
const is404 =
|
||||||
@@ -34,10 +39,21 @@ export const actions: Actions = {
|
|||||||
update: async ({ request, locals, params }) => {
|
update: async ({ request, locals, params }) => {
|
||||||
const id = params.id;
|
const id = params.id;
|
||||||
if (!id) return fail(400, { error: "Missing id", form: "update" });
|
if (!id) return fail(400, { error: "Missing id", form: "update" });
|
||||||
|
const user = locals.user as { id: string; Admin?: boolean } | null;
|
||||||
|
const isAdmin = user?.Admin === true;
|
||||||
|
try {
|
||||||
|
const existing = await locals.pb.collection(COLLECTION).getOne<StackqReceipt>(id);
|
||||||
|
if (!isAdmin && user?.id && existing.User !== user.id) {
|
||||||
|
return fail(404, { error: "Receipt not found", form: "update" });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return fail(404, { error: "Receipt not found", form: "update" });
|
||||||
|
}
|
||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const Status = (form.get("Status") as string)?.trim() || "New";
|
const Status = (form.get("Status") as string)?.trim() || "New";
|
||||||
const Type = (form.get("Type") as string)?.trim() || "Purchase";
|
const Type = (form.get("Type") as string)?.trim() || "Purchase";
|
||||||
|
const Description = (form.get("Description") as string)?.trim() ?? "";
|
||||||
const dataJson = (form.get("dataJson") as string)?.trim() ?? "{}";
|
const dataJson = (form.get("dataJson") as string)?.trim() ?? "{}";
|
||||||
const imageFile = form.get("Image") as File | null;
|
const imageFile = form.get("Image") as File | null;
|
||||||
const hasImage = imageFile && imageFile.size > 0;
|
const hasImage = imageFile && imageFile.size > 0;
|
||||||
@@ -94,6 +110,7 @@ export const actions: Actions = {
|
|||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
Status: STATUS_OPTIONS.includes(Status as (typeof STATUS_OPTIONS)[number]) ? Status : "New",
|
Status: STATUS_OPTIONS.includes(Status as (typeof STATUS_OPTIONS)[number]) ? Status : "New",
|
||||||
Type: TYPE_OPTIONS.includes(Type as (typeof TYPE_OPTIONS)[number]) ? Type : "Purchase",
|
Type: TYPE_OPTIONS.includes(Type as (typeof TYPE_OPTIONS)[number]) ? Type : "Purchase",
|
||||||
|
Description: Description || undefined,
|
||||||
Data: Object.keys(Data).length ? Data : undefined,
|
Data: Object.keys(Data).length ? Data : undefined,
|
||||||
};
|
};
|
||||||
if (imageToSave) body.Image = imageToSave;
|
if (imageToSave) body.Image = imageToSave;
|
||||||
@@ -113,7 +130,13 @@ export const actions: Actions = {
|
|||||||
delete: async ({ locals, params }) => {
|
delete: async ({ locals, params }) => {
|
||||||
const id = params.id;
|
const id = params.id;
|
||||||
if (!id) return fail(400, { error: "Missing id", form: "delete" });
|
if (!id) return fail(400, { error: "Missing id", form: "delete" });
|
||||||
|
const user = locals.user as { id: string; Admin?: boolean } | null;
|
||||||
|
const isAdmin = user?.Admin === true;
|
||||||
try {
|
try {
|
||||||
|
const existing = await locals.pb.collection(COLLECTION).getOne<StackqReceipt>(id);
|
||||||
|
if (!isAdmin && user?.id && existing.User !== user.id) {
|
||||||
|
return fail(404, { error: "Receipt not found", form: "delete" });
|
||||||
|
}
|
||||||
await locals.pb.collection(COLLECTION).delete(id);
|
await locals.pb.collection(COLLECTION).delete(id);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const message =
|
const message =
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
import * as Dialog from "$lib/components/ui/dialog";
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import * as Select from "$lib/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "$lib/components/ui/table";
|
||||||
import { convertImageToWebP, isAcceptableImageFile, isHeicFile } from "$lib/convert-image-to-webp";
|
import { convertImageToWebP, isAcceptableImageFile, isHeicFile } from "$lib/convert-image-to-webp";
|
||||||
import { POCKETBASE_BASE_URL } from "$lib/pocketbase";
|
import { POCKETBASE_BASE_URL } from "$lib/pocketbase";
|
||||||
import { enhance } from "$app/forms";
|
import { enhance } from "$app/forms";
|
||||||
@@ -120,6 +122,18 @@
|
|||||||
return JSON.stringify(data);
|
return JSON.stringify(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let statusValue = $state("New");
|
||||||
|
let typeValue = $state("Purchase");
|
||||||
|
let descriptionValue = $state("");
|
||||||
|
$effect(() => {
|
||||||
|
const r = receipt;
|
||||||
|
if (r) {
|
||||||
|
statusValue = (r.Status as string) || "New";
|
||||||
|
typeValue = (r.Type as string) || "Purchase";
|
||||||
|
descriptionValue = (r.Description as string) ?? "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let imageInputEl = $state<HTMLInputElement | null>(null);
|
let imageInputEl = $state<HTMLInputElement | null>(null);
|
||||||
let imageFileName = $state<string | null>(null);
|
let imageFileName = $state<string | null>(null);
|
||||||
let selectedImageFile = $state<File | null>(null);
|
let selectedImageFile = $state<File | null>(null);
|
||||||
@@ -293,29 +307,44 @@
|
|||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="Status">Status</Label>
|
<Label for="Status">Status</Label>
|
||||||
<select
|
<input type="hidden" name="Status" value={statusValue} />
|
||||||
id="Status"
|
<Select.Root type="single" bind:value={statusValue}>
|
||||||
name="Status"
|
<Select.Trigger id="Status" class="w-full h-9">
|
||||||
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
|
<span>{statusValue}</span>
|
||||||
>
|
</Select.Trigger>
|
||||||
{#each STATUS_OPTIONS as o}
|
<Select.Content>
|
||||||
<option value={o} selected={receipt.Status === o}>{o}</option>
|
{#each STATUS_OPTIONS as o}
|
||||||
{/each}
|
<Select.Item value={o} label={o} />
|
||||||
</select>
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="Type">Type</Label>
|
<Label for="Type">Type</Label>
|
||||||
<select
|
<input type="hidden" name="Type" value={typeValue} />
|
||||||
id="Type"
|
<Select.Root type="single" bind:value={typeValue}>
|
||||||
name="Type"
|
<Select.Trigger id="Type" class="w-full h-9">
|
||||||
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
|
<span>{typeValue}</span>
|
||||||
>
|
</Select.Trigger>
|
||||||
{#each TYPE_OPTIONS as o}
|
<Select.Content>
|
||||||
<option value={o} selected={receipt.Type === o}>{o}</option>
|
{#each TYPE_OPTIONS as o}
|
||||||
{/each}
|
<Select.Item value={o} label={o} />
|
||||||
</select>
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="Description">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="Description"
|
||||||
|
name="Description"
|
||||||
|
type="text"
|
||||||
|
bind:value={descriptionValue}
|
||||||
|
placeholder="materials purchased"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -323,52 +352,52 @@
|
|||||||
<Button type="button" variant="outline" size="sm" onclick={addLine}>Add line</Button>
|
<Button type="button" variant="outline" size="sm" onclick={addLine}>Add line</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full text-sm">
|
<Table>
|
||||||
<thead>
|
<TableHeader>
|
||||||
<tr class="border-b text-left text-muted-foreground">
|
<TableRow class="text-left text-muted-foreground">
|
||||||
<th class="pb-2 pr-2 font-medium">Item</th>
|
<TableHead class="pb-2 pr-2">Item</TableHead>
|
||||||
<th class="pb-2 pr-2 font-medium">Description</th>
|
<TableHead class="pb-2 pr-2">Description</TableHead>
|
||||||
<th class="w-20 pb-2 pr-2 font-medium">Qty</th>
|
<TableHead class="w-20 pb-2 pr-2">Qty</TableHead>
|
||||||
<th class="w-24 pb-2 pr-2 font-medium">Price</th>
|
<TableHead class="w-24 pb-2 pr-2">Price</TableHead>
|
||||||
<th class="w-9 pb-2"></th>
|
<TableHead class="w-9 pb-2"></TableHead>
|
||||||
</tr>
|
</TableRow>
|
||||||
</thead>
|
</TableHeader>
|
||||||
<tbody>
|
<TableBody>
|
||||||
{#each formData.lineItems as row, i}
|
{#each formData.lineItems as row, i}
|
||||||
<tr class="border-b">
|
<TableRow>
|
||||||
<td class="py-1.5 pr-2">
|
<TableCell class="py-1.5 pr-2">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Item"
|
placeholder="Item"
|
||||||
class="h-8 text-sm"
|
class="h-8 text-sm"
|
||||||
bind:value={formData.lineItems[i].item}
|
bind:value={formData.lineItems[i].item}
|
||||||
/>
|
/>
|
||||||
</td>
|
</TableCell>
|
||||||
<td class="py-1.5 pr-2">
|
<TableCell class="py-1.5 pr-2">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Description"
|
placeholder="Description"
|
||||||
class="h-8 text-sm"
|
class="h-8 text-sm"
|
||||||
bind:value={formData.lineItems[i].description}
|
bind:value={formData.lineItems[i].description}
|
||||||
/>
|
/>
|
||||||
</td>
|
</TableCell>
|
||||||
<td class="py-1.5 pr-2">
|
<TableCell class="py-1.5 pr-2">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="1"
|
step="1"
|
||||||
class="h-8 text-sm"
|
class="h-8 text-sm"
|
||||||
bind:value={formData.lineItems[i].quantity}
|
bind:value={formData.lineItems[i].quantity}
|
||||||
/>
|
/>
|
||||||
</td>
|
</TableCell>
|
||||||
<td class="py-1.5 pr-2">
|
<TableCell class="py-1.5 pr-2">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
class="h-8 text-sm"
|
class="h-8 text-sm"
|
||||||
bind:value={formData.lineItems[i].price}
|
bind:value={formData.lineItems[i].price}
|
||||||
/>
|
/>
|
||||||
</td>
|
</TableCell>
|
||||||
<td class="py-1.5">
|
<TableCell class="py-1.5">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -379,11 +408,11 @@
|
|||||||
>
|
>
|
||||||
×
|
×
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user