initial commit

This commit is contained in:
faiztyanirh 2026-02-01 21:47:55 +07:00
commit 0c1e54fc3d
234 changed files with 12945 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

9
.prettierignore Normal file
View File

@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

15
.prettierrc Normal file
View File

@ -0,0 +1,15 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

38
README.md Normal file
View File

@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

16
components.json Normal file
View File

@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src\\app.css",
"baseColor": "slate"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": false,
"registry": "https://shadcn-svelte.com/registry"
}

26
eslint.config.js Normal file
View File

@ -0,0 +1,26 @@
import prettier from 'eslint-config-prettier';
import { fileURLToPath } from 'node:url';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
/** @type {import('eslint').Linter.Config[]} */ export default [
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } }
},
{
files: ['**/*.svelte', '**/*.svelte.js'],
languageOptions: { parserOptions: { svelteConfig } }
}
];

13
jsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

3742
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "shadcn5",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.39.1",
"@internationalized/date": "^3.10.1",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tanstack/table-core": "^8.21.3",
"@types/node": "^22",
"bits-ui": "^2.15.4",
"clsx": "^2.1.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.1",
"globals": "^16.5.0",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.45.6",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tw-animate-css": "^1.4.0",
"vite": "^7.2.6"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"mime-types": "^3.0.2",
"mode-watcher": "^1.1.0",
"tailwindcss": "^4.1.18",
"zod": "^4.3.5"
}
}

121
src/app.css Normal file
View File

@ -0,0 +1,121 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

11
src/app.html Normal file
View File

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

110
src/lib/api/api-client.js Normal file
View File

@ -0,0 +1,110 @@
import { API } from '$lib/config/api.js';
import { cleanEmptyStrings } from '$lib/utils/cleanEmptyStrings';
function cleanQuery(searchQuery) {
const result = {};
for (const key in searchQuery) {
if (
searchQuery[key] !== null &&
searchQuery[key] !== undefined &&
searchQuery[key] !== ''
) {
result[key] = searchQuery[key];
}
}
return result;
}
export async function getById(endpoint, id) {
try {
const res = await fetch(`${API.BASE_URL}${endpoint}/${id}`);
if (!res.ok) {
const error = await res.json();
console.error('API Error:', error);
return { data: null, error };
}
const response = await res.json();
return { data: response.data?.[0] || response.data, error: null };
} catch (err) {
console.error('Network Error:', err);
return { data: null, error: err };
}
}
export async function searchWithParams(endpoint, searchQuery) {
try {
const cleanSearchQuery = cleanQuery(searchQuery);
const params = new URLSearchParams(cleanSearchQuery).toString();
const url = params
? `${API.BASE_URL}${endpoint}?${params}`
: `${API.BASE_URL}${endpoint}`;
const res = await fetch(url);
const data = await res.json();
return data.data || [];
} catch (err) {
console.error('Search Error:', err);
return [];
}
}
export async function searchWithPath(endpoint, searchQuery) {
try {
const entries = Object.entries(searchQuery)
.filter(([_, v]) => v !== null && v !== undefined && v !== '');
let url = `${API.BASE_URL}${endpoint}`;
if (entries.length > 0) {
const path = entries.map(([k, v]) => `${k}/${v}`).join('/');
url = `${url}/${path}`;
}
const res = await fetch(url);
const data = await res.json();
return data.data || [];
} catch (err) {
console.error('Search Error:', err);
return [];
}
}
export async function create(endpoint, formData) {
console.log(cleanEmptyStrings(formData));
try {
const res = await fetch(`${API.BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(cleanEmptyStrings(formData))
});
const data = await res.json();
return data;
} catch (err) {
console.error('Create Error:', err.message);
return { success: false, message: err.message || 'Network error' };
}
}
export async function update(endpoint, formData) {
console.log(cleanEmptyStrings(formData));
try {
const res = await fetch(`${API.BASE_URL}${endpoint}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(cleanEmptyStrings(formData))
});
const data = await res.json();
return data;
} catch (err) {
console.error('Update Error:', err.message);
return { success: false, message: err.message || 'Network error' };
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,166 @@
<script module>
import ChartPieIcon from "@lucide/svelte/icons/chart-pie";
import FrameIcon from "@lucide/svelte/icons/frame";
import LifeBuoyIcon from "@lucide/svelte/icons/life-buoy";
import MapIcon from "@lucide/svelte/icons/map";
import SendIcon from "@lucide/svelte/icons/send";
import Settings2Icon from "@lucide/svelte/icons/settings-2";
import SquareTerminalIcon from "@lucide/svelte/icons/square-terminal";
const data = {
user: {
name: "shadcn",
email: "m@example.com",
avatar: "",
},
navMain: [
{
title: "Dashboard",
url: "/",
icon: LifeBuoyIcon,
},
{
title: "Patient",
url: "/patient",
icon: LifeBuoyIcon,
},
{
title: "Order",
url: "/order",
icon: LifeBuoyIcon,
isActive: false,
submenus: [
{
title: "Test Order",
url: "/testorder",
},
],
},
],
dictionary: [
{
title: "Admission",
url: "#",
icon: LifeBuoyIcon,
submenus: [
{
title: "Contact",
url: "#",
},
{
title: "Location",
url: "#",
},
{
title: "Occupation",
url: "#",
},
],
},
{
title: "Value",
url: "#",
icon: LifeBuoyIcon,
submenus: [
{
title: "Value Set Def",
url: "#",
},
{
title: "Value Set",
url: "#",
},
],
},
{
title: "Sample",
url: "#",
icon: LifeBuoyIcon,
submenus: [
{
title: "Container",
url: "#",
},
],
},
{
title: "Organization",
url: "#",
icon: LifeBuoyIcon,
submenus: [
{
title: "Account",
url: "#",
},
{
title: "Site",
url: "#",
},
{
title: "Discipline",
url: "#",
},
{
title: "Department",
url: "#",
},
{
title: "Workstation",
url: "#",
},
],
},
{
title: "Test",
url: "#",
icon: LifeBuoyIcon,
submenus: [
{
title: "Test Site",
url: "#",
},
],
},
]
};
</script>
<script>
import NavMain from "./nav-main.svelte";
import NavDictionary from "./nav-dictionary.svelte";
import NavUser from "./nav-user.svelte";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import PandaIcon from "@lucide/svelte/icons/panda";
let { ref = $bindable(null), collapsible = "icon", ...restProps } = $props();
</script>
<Sidebar.Root {collapsible} {...restProps}>
<Sidebar.Header>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton size="lg">
{#snippet child({ props })}
<a href="##" {...props}>
<div
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
>
<PandaIcon class="size-6" />
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">5PANDAWA</span>
<span class="truncate text-xs">Tilis</span>
</div>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Header>
<Sidebar.Content>
<NavMain items={data.navMain} />
<NavDictionary dictionary={data.dictionary} />
</Sidebar.Content>
<Sidebar.Footer>
<NavUser user={data.user} />
</Sidebar.Footer>
</Sidebar.Root>

View File

@ -0,0 +1,99 @@
const optionsMode = {
default: async (field, selectOptions, loadingOptions) => {
if (selectOptions[field.key]?.length > 0) return;
loadingOptions[field.key] = true;
try {
const res = await fetch(field.optionsEndpoint);
const json = await res.json();
selectOptions[field.key] = json?.data ?? [];
} catch (err) {
console.error("Failed to fetch options for", field.key, err);
selectOptions[field.key] = [];
} finally {
loadingOptions[field.key] = false;
}
},
cascade: async (field, selectOptions, loadingOptions, form, lastFetched) => {
const parentValue = field.dependsOn ? form?.[field.dependsOn] : null;
// If has dependency and parent changed, or not fetched yet
if (field.dependsOn) {
// If parent value exists and already fetched for this parent value, skip
if (selectOptions[field.key]?.length > 0 &&
lastFetched[field.key] === parentValue) {
return;
}
// If no parent value, clear options
if (!parentValue) {
selectOptions[field.key] = [];
return;
}
} else {
// Non-dependent field, only fetch once
if (selectOptions[field.key]?.length > 0) return;
}
let endpoint = field.optionsEndpoint;
// Add parent parameter if exists
if (parentValue && field.endpointParamKey) {
endpoint += `?${field.endpointParamKey}=${parentValue}`;
}
loadingOptions[field.key] = true;
try {
const res = await fetch(endpoint);
const json = await res.json();
selectOptions[field.key] = json?.data ?? [];
// Track last fetched parent value for dependent fields
if (field.dependsOn) {
lastFetched[field.key] = parentValue;
}
} catch (err) {
console.error("Failed to fetch options for", field.key, err);
selectOptions[field.key] = [];
} finally {
loadingOptions[field.key] = false;
}
}
};
export function useFormOptions(optMode = 'default') {
const selectOptions = $state({});
const loadingOptions = $state({});
const lastFetched = $state({});
async function fetchOptions(field, form = null) {
if (!field?.optionsEndpoint) return;
const modeFn = optionsMode[optMode];
if (!modeFn) return;
await modeFn(field, selectOptions, loadingOptions, form, lastFetched);
}
function clearDependentOptions(parentKey, dependentKeys, form) {
dependentKeys.forEach(key => {
selectOptions[key] = [];
if (form) form[key] = '';
lastFetched[key] = null;
});
}
return {
selectOptions,
loadingOptions,
lastFetched,
fetchOptions,
clearDependentOptions,
};
}

View File

@ -0,0 +1,15 @@
export function useFormState(initial) {
const form = $state(structuredClone(initial))
const isSaving = $state({ current: false });
function resetForm() {
Object.assign(form, structuredClone(initial));
}
function setForm(data) {
const snapshotData = $state.snapshot(data);
Object.assign(form, JSON.parse(JSON.stringify(snapshotData)));
}
return { isSaving, form, resetForm, setForm }
}

View File

@ -0,0 +1,31 @@
const validationMode = {
create: (schema, field, value) => {
const result = schema.shape[field].safeParse(value);
return result.success ? null : result.error.issues[0].message;
},
edit: (schema, field, value, originalValue) => {
if (originalValue !== undefined && value === originalValue) {
return null;
}
const result = schema.shape[field].safeParse(value);
return result.success ? null : result.error.issues[0].message;
}
};
export function useFormValidation(schema, form, defaultErrors, valMode) {
const errors = $state({...defaultErrors})
function validateField(field, originalValue) {
const value = form[field];
const valFn = validationMode[valMode];
errors[field] = valFn(schema, field, value, originalValue);
}
function resetErrors() {
Object.assign(errors, defaultErrors);
}
return { errors, validateField, resetErrors }
}

View File

@ -0,0 +1,37 @@
import { useFormState } from "./use-form-state.svelte";
import { useFormOptions } from "./use-form-option.svelte";
import { useFormValidation } from "./use-form-validation.svelte";
export function useForm({schema, initialForm, defaultErrors, mode, modeOpt, saveEndpoint, editEndpoint}) {
const state = useFormState(initialForm);
const val = useFormValidation(schema, state.form, defaultErrors, mode);
const options = useFormOptions(modeOpt);
async function save() {
state.isSaving.current = true
try {
const payload = { ...state.form };
const result = mode === 'edit' ? await editEndpoint(payload) : await saveEndpoint(payload)
return result;
} catch (error) {
console.error('Save failed', error);
return { status: 'error', message: 'Save failed' };
} finally {
state.isSaving.current = false;
}
}
function reset() {
state.resetForm();
val.resetErrors();
}
return {
...state, //form, resetForm, setForm, isSaving
...val, //errors, validateField, resetErrors
...options, //selectOptions, loadingOptions, fetchOptions, lastFetched, clearDependentOptions
save,
reset,
}
}

View File

@ -0,0 +1,102 @@
import { API } from "$lib/config/api";
import { z } from "zod";
export function usePatientForm(formState, patientSchema) {
let uploadErrors = $state({});
let isChecking = $state({});
async function validateFieldAsync(field) {
isChecking[field] = true;
try {
const asyncSchema = patientSchema.extend({
PatientID: patientSchema.shape.PatientID.refine(
async (value) => {
if (!value) return false;
const res = await fetch(`${API.BASE_URL}${API.CHECK}?PatientID=${value}`);
const { status, data } = await res.json();
return status === "success" && data === false ? false : true;
},
{ message: "Patient ID already used" }
)
});
const partial = asyncSchema.pick({ [field]: true });
const result = await partial.safeParseAsync({ [field]: formState.form[field] });
formState.errors[field] = result.success ? null : result.error.issues[0].message;
} catch (err) {
console.error('Async validation error:', err);
} finally {
isChecking[field] = false;
}
}
function validateIdentifier() {
const identifierType = formState.form.PatIdt.IdentifierType;
const identifierValue = formState.form.PatIdt.Identifier;
if (!identifierType || !identifierValue) {
formState.errors['PatIdt.Identifier'] = null;
return;
}
const schema = getIdentifierValidation(identifierType);
const result = schema.safeParse(identifierValue);
formState.errors['PatIdt.Identifier'] = result.success ? null : result.error.issues[0].message;
}
function getIdentifierValidation(identifierType) {
switch (identifierType) {
case 'KTP':
return z.string()
.length(16, "Must be 16 characters")
.regex(/^$|^[0-9]+$/, "Can only contain numbers");
case 'PASS':
return z.string()
.max(9, "Max 9 chars")
.regex(/^[A-Z0-9]+$/, "Must be uppercase letters and numbers");
case 'SSN':
return z.string()
.max(9, "Max 9 chars")
.regex(/^$|^[0-9]+$/, "Can only contain numbers");
case 'SIM':
return z.string()
.max(20, "Max 20 chars")
.regex(/^$|^[0-9]+$/, "Can only contain numbers");
case 'KTAS':
return z.string()
.max(11, "Max 11 chars")
.regex(/^[A-Z0-9]+$/, "Must be uppercase letters and numbers");
default:
return z.string().min(1, "Identifier required");
}
}
let linkToDisplay = $derived(
Array.isArray(formState.form.LinkTo)
? formState.form.LinkTo
.map(p => p.PatientID)
.filter(Boolean)
.join(', ')
: ''
);
let hasErrors = $derived(
Object.values(formState.errors).some(value => value !== null)
);
return {
uploadErrors,
isChecking,
validateFieldAsync,
validateIdentifier,
get linkToDisplay() { return linkToDisplay },
get hasErrors() { return hasErrors },
}
}

View File

@ -0,0 +1,136 @@
import { useResponsive } from "./useResponsive.svelte.js";
export function useMasterDetail(options = {}) {
const { confirmMessage = "You have unsaved changes. Discard them?", onSelect = null, } = options;
let selectedItem = $state(null);
let mode = $state("view");
let isLoadingDetail = $state(false);
// Form state
let form = $state({});
let formSnapshot = $state({});
const { isMobile } = useResponsive();
// Derived states
const isFormMode = $derived(mode === "create" || mode === "edit");
const showMaster = $derived(!isMobile || (mode === "view" && !selectedItem));
const showDetail = $derived(!isMobile || selectedItem || isFormMode);
const layout = $derived({
masterWidth: isMobile ? "w-full" : isFormMode ? "w-[3%]" : "w-[35%]",
detailWidth: isMobile ? "w-full" : isFormMode ? "w-[97%]" : "w-[65%]",
});
const isDirty = $derived(
JSON.stringify(form) !== JSON.stringify(formSnapshot)
);
// Actions
async function select(item) {
mode = "view";
if (onSelect) {
isLoadingDetail = true;
try {
const detailData = await onSelect(item);
selectedItem = detailData;
} catch (error) {
console.error("Failed to fetch detail:", error);
selectedItem = null;
} finally {
isLoadingDetail = false;
}
} else {
selectedItem = item;
}
}
function enterCreate(initialValues = {}) {
mode = "create";
selectedItem = null;
form = { ...initialValues };
formSnapshot = { ...initialValues };
}
function enterEdit(mapToForm = null) {
if (!selectedItem) return;
mode = "edit";
// Auto exclude 'id' or use custom mapping
const formData = mapToForm
? mapToForm(selectedItem)
: (() => {
const { id, ...rest } = selectedItem;
return rest;
})();
form = { ...formData };
formSnapshot = { ...formData };
}
function exitForm(force = false) {
if (!force && isDirty) {
const ok = confirm(confirmMessage);
if (!ok) return;
}
mode = "view";
selectedItem = null;
}
function backToList() {
selectedItem = null;
mode = "view";
}
function saveForm() {
// Commit changes (mark as saved)
formSnapshot = { ...form };
}
return {
// State
get selectedItem() {
return selectedItem;
},
get mode() {
return mode;
},
get isFormMode() {
return isFormMode;
},
get isMobile() {
return isMobile;
},
get showMaster() {
return showMaster;
},
get showDetail() {
return showDetail;
},
get layout() {
return layout;
},
get form() {
return form;
},
get isDirty() {
return isDirty;
},
get isLoadingDetail() {
return isLoadingDetail;
},
// Actions
select,
enterCreate,
enterEdit,
exitForm,
backToList,
saveForm,
};
}

View File

@ -0,0 +1,20 @@
export function useResponsive(breakpoint = 768) {
let isMobile = $state(false);
$effect(() => {
const checkMobile = () => {
isMobile = window.innerWidth < breakpoint;
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
});
return {
get isMobile() {
return isMobile;
},
};
}

View File

@ -0,0 +1,41 @@
export function useSearch(searchFields, searchApiFunction) {
let searchQuery = $state(initializeSearchQuery(searchFields));
let isLoading = $state(false);
let searchData = $state([]);
function initializeSearchQuery(fields) {
const query = {};
for (const field of fields) {
if (field.type === "select") {
query[field.key] = field.default ?? "";
} else {
query[field.key] = "";
}
}
return query;
}
async function handleSearch() {
isLoading = true;
try {
searchData = await searchApiFunction(searchQuery);
} catch (error) {
console.error('Search failed:', error);
} finally {
isLoading = false;
}
}
function handleReset() {
searchQuery = initializeSearchQuery(searchFields);
}
return {
get searchQuery() { return searchQuery; },
set searchQuery(value) { searchQuery = value; },
get searchData() { return searchData; },
get isLoading() { return isLoading; },
handleSearch,
handleReset
};
}

View File

@ -0,0 +1,61 @@
<script>
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import * as Collapsible from "$lib/components/ui/collapsible/index.js";
import { useSidebar } from "$lib/components/ui/sidebar/index.js";
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
import FolderIcon from "@lucide/svelte/icons/folder";
import ShareIcon from "@lucide/svelte/icons/share";
import Trash2Icon from "@lucide/svelte/icons/trash-2";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
let {
dictionary,
} = $props();
const sidebar = useSidebar();
</script>
<Sidebar.Group>
<Sidebar.GroupLabel>Dictionary</Sidebar.GroupLabel>
<Sidebar.Menu>
{#each dictionary as item (item.title)}
<Collapsible.Root open={item.isActive}>
<Sidebar.MenuItem>
<Sidebar.MenuButton tooltipContent={item.title}>
{#snippet child({ props })}
<a href={item.url} {...props}>
<item.icon />
<span>{item.title}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
{#if item.items?.length}
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuAction
{...props}
class="data-[state=open]:rotate-90"
>
<ChevronRightIcon />
<span class="sr-only">Toggle</span>
</Sidebar.MenuAction>
{/snippet}
</Collapsible.Trigger>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each item.items as subItem (subItem.title)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton href={subItem.url}>
<span>{subItem.title}</span>
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
{/if}
</Sidebar.MenuItem>
</Collapsible.Root>
{/each}
</Sidebar.Menu>
</Sidebar.Group>

View File

@ -0,0 +1,115 @@
<script>
import * as Collapsible from "$lib/components/ui/collapsible/index.js";
import * as Popover from "$lib/components/ui/popover/index.js";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import { Separator } from "$lib/components/ui/separator/index.js";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { useSidebar } from "$lib/components/ui/sidebar/index.js";
import { page } from "$app/stores";
let {
items,
} = $props();
const sidebar = Sidebar.useSidebar();
let openPopovers = $state({});
</script>
<Sidebar.Group>
<Sidebar.GroupLabel>Menu</Sidebar.GroupLabel>
<Sidebar.Menu>
{#each items as item, index}
{#if sidebar.state === "expanded"}
<Collapsible.Root open={item.isActive} class="group/collapsible">
{#snippet child({ props })}
<Sidebar.MenuItem {...props}>
<Sidebar.MenuButton tooltipContent={item.title}>
{#snippet child({ props })}
{#if item.submenus?.length}
<a {...props}>
<item.icon />
<span>{item.title}</span>
</a>
{:else}
<a href={item.url} {...props}>
<item.icon />
<span>{item.title}</span>
</a>
{/if}
{/snippet}
</Sidebar.MenuButton>
{#if item.submenus?.length}
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuAction
{...props}
class="data-[state=open]:rotate-90"
>
<ChevronRightIcon />
<span class="sr-only">Toggle</span>
</Sidebar.MenuAction>
{/snippet}
</Collapsible.Trigger>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each item.submenus as subItem (subItem.title)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton href={`${item.url}${subItem.url}`}>
<span>{subItem.title}</span>
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
{/if}
</Sidebar.MenuItem>
{/snippet}
</Collapsible.Root>
{:else}
<Popover.Root open={openPopovers[item.url]} onOpenChange={(open) => openPopovers[item.url] = open}>
<Sidebar.MenuItem>
{#snippet trigger(props)}
<Popover.Trigger {...props}>
<Sidebar.MenuButton tooltip={item.title}>
{#if item.icon && !item.submenu}
<item.icon />
{/if}
<span>{item.title}</span>
</Sidebar.MenuButton>
</Popover.Trigger>
{/snippet}
{@render trigger()}
</Sidebar.MenuItem>
<Popover.Content side="right" align="start" class="w-max p-1">
<div class="space-y-1">
{#if item.submenus && item.submenus.length > 0}
<div class="px-2 py-1.5 text-sm font-semibold">
{item.title}
</div>
<Separator />
{#each item.submenus || [] as submenu}
<a href={submenu.url}
onclick={() => openPopovers[item.url] = false}
class="flex items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
class:bg-accent={$page.url.pathname === submenu.url}
>
{submenu.title}
</a>
{/each}
{:else}
<a href={item.url}
onclick={() => openPopovers[item.url] = false}
class="flex items-center rounded-md px-2 py-1.5 text-sm font-semibold hover:bg-accent"
class:bg-accent={$page.url.pathname === item.url}
>
{item.title}
</a>
{/if}
</div>
</Popover.Content>
</Popover.Root>
{/if}
{/each}
</Sidebar.Menu>
</Sidebar.Group>

View File

@ -0,0 +1,87 @@
<script>
import * as Avatar from "$lib/components/ui/avatar/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import { useSidebar } from "$lib/components/ui/sidebar/index.js";
import BadgeCheckIcon from "@lucide/svelte/icons/badge-check";
import BellIcon from "@lucide/svelte/icons/bell";
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
import CreditCardIcon from "@lucide/svelte/icons/credit-card";
import LogOutIcon from "@lucide/svelte/icons/log-out";
import SparklesIcon from "@lucide/svelte/icons/sparkles";
let { user } = $props();
const sidebar = useSidebar();
</script>
<Sidebar.Menu>
<Sidebar.MenuItem>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
{...props}
>
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Image src={user.avatar} alt={user.name} />
<Avatar.Fallback class="rounded-lg">CN</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-start text-sm leading-tight">
<span class="truncate font-medium">{user.name}</span>
<span class="truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDownIcon class="ms-auto size-4" />
</Sidebar.MenuButton>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
side={sidebar.isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenu.Label class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Image src={user.avatar} alt={user.name} />
<Avatar.Fallback class="rounded-lg">CN</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-start text-sm leading-tight">
<span class="truncate font-medium">{user.name}</span>
<span class="truncate text-xs">{user.email}</span>
</div>
</div>
</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item>
<SparklesIcon />
Upgrade to Pro
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item>
<BadgeCheckIcon />
Account
</DropdownMenu.Item>
<DropdownMenu.Item>
<CreditCardIcon />
Billing
</DropdownMenu.Item>
<DropdownMenu.Item>
<BellIcon />
Notifications
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item>
<LogOutIcon />
Log out
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>

View File

@ -0,0 +1,21 @@
import { API } from '$lib/config/api.js';
import { getById, searchWithParams, create, update } from '$lib/api/api-client';
export async function searchParam(searchQuery) {
return await searchWithParams(API.PATIENTS, searchQuery)
}
export async function getPatient(searchQuery) {
const { data: patient, error } = await getById(API.PATIENTS, searchQuery)
return { patient };
}
export async function createPatient(newContactForm) {
// console.log(JSON.stringify(newContactForm));
return await create(API.PATIENTS, newContactForm)
}
export async function editPatient(editContactForm) {
// console.log(JSON.stringify(editContactForm));
return await update(API.PATIENTS, editContactForm)
}

View File

@ -0,0 +1,145 @@
import PlusIcon from "@lucide/svelte/icons/plus";
import Settings2Icon from "@lucide/svelte/icons/settings-2";
import FlaskConicalIcon from "@lucide/svelte/icons/flask-conical";
import ActivityIcon from "@lucide/svelte/icons/activity";
import PencilIcon from "@lucide/svelte/icons/pencil";
import NotepadTextIcon from "@lucide/svelte/icons/notepad-text";
export const searchFields = [
{
key: "PatientID",
label: "Patient ID",
placeholder: "",
type: "text",
defaultValue: "",
},
{
key: "Name",
label: "Patient Name",
placeholder: "",
type: "text",
defaultValue: "",
},
{
key: "Birthdate",
label: "Birthdate",
type: "date"
},
];
export const detailSections = [
{
class: "grid grid-cols-2 lg:grid-cols-3 gap-3",
fields: [
{ key: "PatientID", label: "Patient ID", },
{ parentKey: "PatIdt", key: "IdentifierType", label: "Identifier Type", },
{ key: "Birthdate", label: "Date of Birth", },
{ key: "SexLabel", label: "Sex", },
{ parentKey: "PatIdt", key: "Identifier", label: "Identifier", },
{ key: "Age", label: "Age", },
]
},
{
class: "grid grid-cols-1 sm:grid-cols-3 gap-3",
fields: [
{ key: "ReligionLabel", label: "Religion" },
{ keys: ["Street_1", "Street_2", "Street_3"], className: "row-span-2", label: "Address" },
{ key: "EmailAddress1", label: "Email Address 1" },
{ key: "MaritalStatusLabel", label: "Marital Status" },
{ key: "EmailAddress2", label: "Email Address 2" },
{ key: "EthnicLabel", label: "Ethnic" },
{
isGroup: true,
class: "grid grid-cols-2",
fields: [
{ key: "City", label: "City" },
{ key: "ZIP", label: "ZIP" },
],
},
{ key: "Phone", label: "Phone" },
{ key: "RaceLabel", label: "Race" },
{
isGroup: true,
class: "grid grid-cols-2",
fields: [
{ key: "Province", label: "Province" },
{ key: "CountryLabel", label: "Country" },
],
},
{ key: "MobilePhone", label: "Mobile Phone" },
{ key: "Citizenship", label: "Citizenship" },
{
isGroup: true,
class: "grid grid-cols-2",
fields: [
{ key: "LinkTo", label: "Link Patient" },
{ parentKey: "Custodian", key: "PatientID", label: "Custodian ID" },
],
},
{ key: "DeathIndicatorLabel", label: "Death Indicator" },
{ key: "CreateDate", label: "Create Date", isUTCDate: true },
{ key: "DelDate", label: "Disabled Date" },
{ key: "TimeOfDeath", label: "Death Date", isUTCDate: true },
]
},
{
class: "grid grid-cols-1 sm:grid-cols-2 gap-3",
fields: [
{ key: "", label: "Patient Visit ID" },
{ key: "", label: "Insurance" },
{ key: "", label: "Visit Class" },
{ key: "", label: "Service Class" },
{ key: "", label: "Location" },
{ key: "", label: "Doctor" },
{ key: "", label: "Admission Date", isUTCDate: true },
{ key: "", label: "Discharge Date", isUTCDate: true },
]
},
{
class: "grid grid-cols-1 sm:grid-cols-2 gap-3",
fields: [
{ key: "PatCom", label: "Patient Comment" },
{ key: "PatAtt", label: "Patient File", isFileList: true },
]
}
]
export function patientActions(masterDetail) {
return [
{
Icon: PlusIcon,
label: 'Add Patient',
onClick: masterDetail.enterCreate,
},
{
Icon: Settings2Icon,
label: 'Search Parameters',
},
];
}
export function viewActions(handlers){
return [
{
Icon: FlaskConicalIcon,
label: 'Order Lab',
onClick: handlers.orderLab,
},
{
Icon: ActivityIcon,
label: 'Medical Record',
onClick: handlers.medicalRecord,
},
{
Icon: NotepadTextIcon,
label: 'Audit Patient',
onClick: handlers.auditPatient,
},
{
Icon: PencilIcon,
label: 'Edit Patient',
onClick: handlers.editPatient,
},
]
}

View File

@ -0,0 +1,311 @@
import { API } from "$lib/config/api";
import EraserIcon from "@lucide/svelte/icons/eraser";
import { z } from "zod";
export const patientSchema = z.object({
PatientID: z.string().min(1, "Required"),
Sex: z.string().min(1, "Required"),
NameFirst: z.string().min(1, "Required"),
Birthdate: z.string().min(1, "Required").refine(
(date) => new Date(date) <= new Date(),
"Cannot exceed today's date"
),
EmailAddress1: z.string().min(1, "Required").email("Invalid email format"),
EmailAddress2: z.string().trim().optional().refine((val) => !val || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val),"Invalid email format"),
Phone: z.string().max(14, "Max 14 chars").regex(/^$|^[0-9]+$/, "Can only contain numbers"),
MobilePhone: z.string().max(14, "Max 14 chars").regex(/^$|^[0-9]+$/, "Can only contain numbers"),
TimeOfDeath: z.string().optional(),
});
export const patientInitialForm = {
PatientID: "",
AlternatePID: "",
PatIdt: {
IdentifierType: "",
Identifier: ""
},
NameFirst: "",
Prefix: "",
Sex: "",
Religion: "",
NameMiddle: "",
NameMaiden: "",
MaritalStatus: "",
NameLast: "",
Custodian: {
InternalPID: "",
PatientID: ""
},
Ethnic: "",
Suffix: "",
PlaceOfBirth: "",
Race: "",
Birthdate: "",
Citizenship: "",
Street_1: "",
City: "",
Street_2: "",
Province: "",
Street_3: "",
ZIP: "",
Country: "",
EmailAddress1: "",
Phone: "",
EmailAddress2: "",
MobilePhone: "",
DeathIndicator: "",
TimeOfDeath: "",
LinkTo: [],
PatCom: "",
PatAtt: [],
};
export const patientDefaultErrors = {
PatientID: "Required",
NameFirst: "Required",
Sex: "Required",
Birthdate: "Required",
EmailAddress1: "Required",
EmailAddress2: null,
'PatIdt.Identifier': null,
Phone: null,
MobilePhone: null,
};
export const patientFormFields = [
{
title: "",
rows: [
{
type: "row",
columns: [
{
key: "PatientID",
label: "Patient ID",
required: true,
type: "text",
validateOn: ["input", "blur"]
},
{
key: "AlternatePID",
label: "Alternate PID",
required: false,
type: "text"
},
{
key: "PatIdt.Identifier",
label: "Identifier",
required: false,
type: "identity",
optionsEndpoint: `${API.BASE_URL}${API.VALUESET}/identifier_type`,
}
]
}
]
},
{
title: "Personal Information",
rows: [
{
type: "row",
columns: [
{
key: "NameFirst",
label: "First Name",
required: true,
type: "text",
validateOn: ["input"]
},
{
type: "group",
columns: [
{
key: "Prefix",
label: "Prefix",
required: false,
type: "text"
},
]
},
{
key: "Religion",
label: "Religion",
required: false,
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.VALUESET}/religion`,
}
]
},
{
type: "row",
columns: [
{ key: "NameMiddle", label: "Middle Name", required: false, type: "text" },
{ key: "NameMaiden", label: "Maiden Name", required: false, type: "text" },
{
key: "MaritalStatus",
label: "Marital Status",
required: false,
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.VALUESET}/marital_status`,
}
]
},
{
type: "row",
columns: [
{ key: "NameLast", label: "Last Name", required: false, type: "text" },
{ key: "Custodian", label: "Custodian", required: false, type: "custodian" },
{
key: "Ethnic",
label: "Ethnic",
required: false,
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.VALUESET}/ethnic`,
}
]
},
{
type: "row",
columns: [
{ key: "Suffix", label: "Suffix", required: false, type: "text" },
{ key: "PlaceOfBirth", label: "Place of Birth", required: false, type: "text" },
{
key: "Race",
label: "Race",
required: false,
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.VALUESET}/race`,
}
]
},
{
type: "row",
columns: [
{
key: "Sex",
label: "Sex",
required: true,
type: "select",
validateOn: ["input"],
optionsEndpoint: `${API.BASE_URL}${API.VALUESET}/sex`,
},
{ key: "Birthdate", label: "Birthdate", required: true, type: "date", validateOn: ["input"] },
{ key: "Citizenship", label: "Citizenship", required: false, type: "text" }
]
}
]
},
{
title: "Address Information",
rows: [
{
type: "row",
columns: [
{ key: "Street_1", label: "Street 1", required: false, type: "text" },
{
key: "Province",
label: "Province",
required: false,
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.PROVINCE}`,
}
]
},
{
type: "row",
columns: [
{ key: "Street_2", label: "Street 2", required: false, type: "text" },
{
key: "City",
label: "City",
required: false,
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.CITY}`,
dependsOn: "Province", // ← field yang jadi parent
endpointParamKey: "Parent" // ← query param name
}
]
},
{
type: "row",
columns: [
{ key: "Street_3", label: "Street 3", required: false, type: "text" },
{
type: "group",
columns: [
{ key: "ZIP", label: "ZIP", required: false, type: "number" },
{
key: "Country",
label: "Country",
required: false,
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.VALUESET}/country`,
}
]
}
]
}
]
},
{
title: "Contact & Other Info",
rows: [
{
type: "row",
columns: [
{ key: "EmailAddress1", label: "Email Address 1", required: true, type: "email", validateOn: ["input", "blur"] },
{ key: "Phone", label: "Phone", required: false, type: "text", validateOn: ["input"] },
]
},
{
type: "row",
columns: [
{ key: "EmailAddress2", label: "Email Address 2", required: false, type: "email", validateOn: ["input"] },
{ key: "MobilePhone", label: "Mobile Phone", required: false, type: "text", validateOn: ["input"] },
]
},
{
type: "row",
columns: [
{
type: "group",
columns: [
{
key: "DeathIndicator",
label: "Deceased",
required: false,
type: "select",
optionsEndpoint: `${API.BASE_URL}${API.VALUESET}/death_indicator`,
defaultValue: 'N'
},
{ key: "TimeOfDeath", label: "Time of Death", required: false, type: "datetime" }
]
},
{ key: "LinkTo", label: "Link To", required: false, type: "linkto" }
]
}
]
},
{
title: "Additional Documents",
rows: [
{
type: "row",
columns: [
{ key: "PatCom", label: "Patient Comment", required: false, type: "textarea" },
{ key: "PatAtt", label: "Patient File", required: false, type: "fileupload" }
]
}
]
}
];
export function getPatientFormActions(handlers) {
return [
{
Icon: EraserIcon,
label: 'Clear Form',
onClick: handlers.clearForm,
},
];
}

View File

@ -0,0 +1,157 @@
<script>
import * as Dialog from "$lib/components/ui/dialog/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import BabyIcon from "@lucide/svelte/icons/baby";
import SearchIcon from "@lucide/svelte/icons/search";
import { Input } from "$lib/components/ui/input/index.js";
import * as Table from "$lib/components/ui/table/index.js";
import { Checkbox } from "$lib/components/ui/checkbox/index.js";
import ReusableCalendar from "$lib/components/reusable/reusable-calendar.svelte";
import { searchParam } from "$lib/components/patient/api/patient-api";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { searchFields } from "../config/patient-config";
import { useSearch } from "$lib/components/composable/useSearch.svelte";
import ReusableEmpty from "$lib/components/reusable/reusable-empty.svelte";
let props = $props();
const search = useSearch(searchFields, searchParam);
let isOpen = $state(false);
let selectedPatient = $state({
InternalPID: null,
PatientID: null,
});
function handleOpenChange(open) {
isOpen = open;
if (open) {
if (props.formState.form.Custodian) {
selectedPatient = {
InternalPID: props.formState.form.Custodian.InternalPID || null,
PatientID: props.formState.form.Custodian.PatientID || null,
};
}
}
}
function confirmCustodian() {
// Update form state dengan selected patient
props.formState.form.Custodian = { ...selectedPatient };
// Reset and close
selectedPatient = { InternalPID: null, PatientID: null };
isOpen = false;
}
function populateCustodian() {
custodianModalMode.mode = mode;
setSelectedPatient(editPatientForm.Custodian);
}
function togglePatientSelection(patient) {
if (selectedPatient.InternalPID === patient.InternalPID) {
selectedPatient = {
InternalPID: null,
PatientID: null,
};
} else {
selectedPatient = {
InternalPID: patient.InternalPID,
PatientID: patient.PatientID,
};
}
}
</script>
{#snippet Fieldset({ key, label, type })}
{#if type === "text"}
<div class="flex w-full flex-col gap-1.5">
<div class="flex justify-between items-center w-full">
<Label>{label}</Label>
</div>
<div class="relative flex flex-col items-center w-full">
<Input type="text" bind:value={search.searchQuery[key]}/>
</div>
</div>
{:else if type === "date"}
<ReusableCalendar title="Birthdate" bind:value={search.searchQuery[key]}/>
{/if}
{/snippet}
<Dialog.Root open={isOpen} onOpenChange={handleOpenChange}>
<Dialog.Trigger>
<Button variant="outline" class="size-9 rounded-l-none cursor-pointer">
<BabyIcon />
</Button>
</Dialog.Trigger>
<Dialog.Content class="flex flex-col max-h-9/10 w-[90vw] max-w-sm sm:max-w-md md:max-w-lg lg:max-w-2xl">
<Dialog.Header class="border-b pb-4">
<Dialog.Title class="text-xl font-semibold">Search Custodian</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
<div class="pb-4 border-b">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{#each searchFields as field}
{@render Fieldset(field)}
{/each}
</div>
</div>
<div class="flex justify-end gap-2 border-b pb-4">
<Button variant="outline" size="sm" class="cursor-pointer" onclick={search.handleReset}>Reset</Button>
<Button size="sm" class="cursor-pointer" onclick={search.handleSearch} disabled={search.isLoading}>
{#if search.isLoading}
<Spinner />
{:else}
Search
{/if}
</Button>
</div>
</div>
<div class="flex-1 overflow-y-auto">
{#if search.searchData.length === 0}
<div class="flex flex-col items-center justify-center text-muted-foreground">
<ReusableEmpty icon={SearchIcon} desc="Try searching from search parameters"/>
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row class="hover:bg-transparent">
<Table.Head class="w-8"></Table.Head>
<Table.Head class="w-32">Patient ID</Table.Head>
<Table.Head class="w-full">Patient Name</Table.Head>
<Table.Head class="w-32">Birthdate</Table.Head>
<Table.Head class="w-8">Sex</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each search.searchData as patient, i}
<Table.Row
class="cursor-pointer hover:bg-muted/50"
onclick={() => togglePatientSelection(patient)}
>
<Table.Cell>
<Checkbox
class="cursor-pointer hover:bg-muted/50"
checked={selectedPatient.InternalPID === patient.InternalPID}
onCheckedChange={() => togglePatientSelection(patient)}
/>
</Table.Cell>
<Table.Cell class="font-medium">{patient.PatientID}</Table.Cell>
<Table.Cell class="">{patient.FullName}</Table.Cell>
<Table.Cell class="text-muted-foreground">{patient.Birthdate ? patient.Birthdate.split(" ")[0] : ""}</Table.Cell>
<Table.Cell class="font-medium">{patient.Gender}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</div>
<Dialog.Footer class="border-t pt-4">
<Button size="sm" class="cursor-pointer" onclick={confirmCustodian}>Select Patient</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@ -0,0 +1,286 @@
<script>
import * as Dialog from "$lib/components/ui/dialog/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import LinkIcon from "@lucide/svelte/icons/link";
import SearchIcon from "@lucide/svelte/icons/search";
import { Input } from "$lib/components/ui/input/index.js";
import * as Table from "$lib/components/ui/table/index.js";
import * as RadioGroup from "$lib/components/ui/radio-group/index.js";
import { searchParam } from "$lib/components/patient/api/patient-api";
import ReusableCalendar from "$lib/components/reusable/reusable-calendar.svelte";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { searchFields } from "../config/patient-config";
import { useSearch } from "$lib/components/composable/useSearch.svelte";
import ReusableEmpty from "$lib/components/reusable/reusable-empty.svelte";
import { Checkbox } from "$lib/components/ui/checkbox/index.js";
let props = $props();
const search = useSearch(searchFields, searchParam);
let isOpen = $state(false);
let selectedPatients = $state([]);
function handleOpenChange(open) {
isOpen = open;
if (open) {
// Populate existing linked patients when opening
if (Array.isArray(props.formState.form.LinkTo)) {
selectedPatients = [...props.formState.form.LinkTo];
} else {
selectedPatients = [];
}
}
// if (open) {
// // Populate existing linked patients when opening
// if (props.formState.form.LinkTo) {
// // Assuming LinkTo is comma-separated InternalPIDs or array
// const linkTo = props.formState.form.LinkTo;
// if (typeof linkTo === 'string') {
// // Parse comma-separated string to array
// selectedPatients = linkTo.split(',')
// .filter(Boolean)
// .map(id => ({ InternalPID: id.trim() }));
// } else if (Array.isArray(linkTo)) {
// selectedPatients = [...linkTo];
// } else {
// selectedPatients = [];
// }
// } else {
// selectedPatients = [];
// }
// }
}
function togglePatientSelection(patient) {
const exists = selectedPatients.some(
p => p.InternalPID === patient.InternalPID
);
if (exists) {
selectedPatients = selectedPatients.filter(
p => p.InternalPID !== patient.InternalPID
);
} else {
selectedPatients = [
...selectedPatients,
{
InternalPID: patient.InternalPID,
PatientID: patient.PatientID
}
];
}
}
function confirmLink() {
// Update form state dengan selected patients
// Store as comma-separated string or array depending on your backend
// props.formState.form.LinkTo = selectedPatients
// .map(p => p.InternalPID)
// .join(',');
// Or as array:
props.formState.form.LinkTo = [...selectedPatients];
// Reset and close
selectedPatients = [];
isOpen = false;
}
function isPatientSelected(patient) {
return selectedPatients.some(
p => p.InternalPID === patient.InternalPID
);
}
</script>
{#snippet Fieldset({ key, label, type })}
{#if type === "text"}
<div class="flex w-full flex-col gap-1.5">
<div class="flex justify-between items-center w-full">
<Label>{label}</Label>
</div>
<div class="relative flex flex-col items-center w-full">
<Input type="text" bind:value={search.searchQuery[key]}/>
</div>
</div>
{:else if type === "date"}
<ReusableCalendar title="Birthdate" bind:value={search.searchQuery[key]}/>
{/if}
{/snippet}
<Dialog.Root open={isOpen} onOpenChange={handleOpenChange}>
<Dialog.Trigger>
<Button variant="outline" class="size-9 rounded-l-none">
<LinkIcon />
</Button>
</Dialog.Trigger>
<Dialog.Content class="flex flex-col max-h-9/10 w-[90vw] max-w-sm sm:max-w-md md:max-w-lg lg:max-w-2xl">
<Dialog.Header class="border-b pb-4">
<Dialog.Title class="text-xl font-semibold">Link Patients</Dialog.Title>
{#if selectedPatients.length > 0}
<p class="text-sm text-muted-foreground">
{selectedPatients.length} patient{selectedPatients.length > 1 ? 's' : ''} selected
</p>
{/if}
</Dialog.Header>
<div class="space-y-4">
<div class="pb-4 border-b">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{#each searchFields as field}
{@render Fieldset(field)}
{/each}
</div>
</div>
<div class="flex justify-end gap-2 border-b pb-4">
<Button
variant="outline"
size="sm"
onclick={search.handleReset}
>
Reset
</Button>
<Button
size="sm"
onclick={search.handleSearch}
disabled={search.isLoading}
>
{#if search.isLoading}
<Spinner />
{:else}
Search
{/if}
</Button>
</div>
</div>
<div class="flex-1 overflow-y-auto">
{#if search.searchData.length === 0}
<div class="flex flex-col items-center justify-center text-muted-foreground">
<ReusableEmpty icon={SearchIcon} desc="Try searching from search parameters"/>
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row class="hover:bg-transparent">
<Table.Head class="w-8"></Table.Head>
<Table.Head class="w-32">Patient ID</Table.Head>
<Table.Head class="w-full">Patient Name</Table.Head>
<Table.Head class="w-32">Birthdate</Table.Head>
<Table.Head class="w-8">Sex</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each search.searchData as patient}
<Table.Row
class="cursor-pointer hover:bg-muted/50"
onclick={() => togglePatientSelection(patient)}
>
<Table.Cell onclick={(e) => e.stopPropagation()}>
<Checkbox
class="cursor-pointer hover:bg-muted/50"
checked={isPatientSelected(patient)}
onCheckedChange={() => togglePatientSelection(patient)}
/>
</Table.Cell>
<Table.Cell class="font-medium">{patient.PatientID}</Table.Cell>
<Table.Cell>{patient.FullName}</Table.Cell>
<Table.Cell class="text-muted-foreground">
{patient.Birthdate ? patient.Birthdate.split(" ")[0] : ""}
</Table.Cell>
<Table.Cell class="font-medium">{patient.Gender}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</div>
<Dialog.Footer class="border-t pt-4">
<Button
size="sm"
onclick={confirmLink}
>
Link Patient
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- <Dialog.Root bind:open={linkModalMode.open}>
<Dialog.Trigger>
<Button variant="outline" class="size-9 rounded-l-none cursor-pointer" onclick={(e) => { linkModalMode.mode = mode }}>
<LinkIcon />
</Button>
</Dialog.Trigger>
<Dialog.Content class="flex flex-col max-h-9/10 w-[90vw] max-w-sm sm:max-w-md md:max-w-lg lg:max-w-2xl">
<Dialog.Header class="border-b pb-4">
<Dialog.Title class="text-xl font-semibold">Search Patient Link</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
<div class="pb-4 border-b">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{#each searchFields as field}
{@render Fieldset(field)}
{/each}
</div>
</div>
<div class="flex justify-end gap-2 border-b pb-4">
<Button variant="outline" size="sm" class="cursor-pointer" onclick={resetAllFields}>Reset</Button>
<Button size="sm" class="cursor-pointer" onclick={handleSearchParam}>
{#if isLoading}
<Spinner />
{:else}
Search
{/if}
</Button>
</div>
</div>
<div class="flex-1 overflow-auto"></div>
{#if linkToPatients.data.length === 0}
<div class="flex flex-col items-center justify-center text-muted-foreground">
<SearchIcon class="w-12 h-12 mb-4 text-primary"/>
<p class="text-sm">No results found. Try searching with different criteria.</p>
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row class="hover:bg-transparent">
<Table.Head class="w-8"></Table.Head>
<Table.Head class="w-32">Patient ID</Table.Head>
<Table.Head class="w-full">Patient Name</Table.Head>
<Table.Head class="w-32">Birthdate</Table.Head>
<Table.Head class="w-8">Sex</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each linkToPatients.data as patient, i}
<Table.Row
class="cursor-pointer hover:bg-muted/50"
>
<Table.Cell>
<Checkbox
checked={selectedPatients.temporaryPatnum.some((p) => p.InternalPID === patient.InternalPID && p.PatientID === patient.PatientID)}
onCheckedChange={() => toggleId(patient.InternalPID, patient.PatientID)}
/>
</Table.Cell>
<Table.Cell class="font-medium">{patient.PatientID}</Table.Cell>
<Table.Cell class="">{patient.FullName}</Table.Cell>
<Table.Cell class="text-muted-foreground">{patient.Birthdate ? patient.Birthdate.split(" ")[0] : ""}</Table.Cell>
<Table.Cell class="font-medium">{patient.Gender}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
<Dialog.Footer class="border-t pt-4">
<Button size="sm" class="cursor-pointer" onclick={confirmLink}>Link Patient</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root> -->

View File

@ -0,0 +1,403 @@
<script>
import TopbarWrapper from "$lib/components/topbar/topbar-wrapper.svelte";
import { Button } from "$lib/components/ui/button/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { useForm } from "$lib/components/composable/use-form.svelte";
import { patientSchema, patientInitialForm, patientDefaultErrors, patientFormFields, getPatientFormActions } from "../config/patient-form-config";
import { createPatient } from "../api/patient-api";
import * as Select from "$lib/components/ui/select/index.js";
import ReusableCalendar from "$lib/components/reusable/reusable-calendar.svelte";
import ReusableCalendarTimepicker from "$lib/components/reusable/reusable-calendar-timepicker.svelte";
import CustodianModal from "../modal/custodian-modal.svelte";
import LinktoModal from "../modal/linkto-modal.svelte";
import ReusableUpload from "$lib/components/reusable/reusable-upload.svelte";
import { API } from "$lib/config/api";
import { z } from "zod";
import FormPageContainer from "../reusable/form-page-container.svelte";
import { usePatientForm } from "$lib/components/composable/use-patient-form.svelte";
import PatientFormRenderer from "../reusable/patient-form-renderer.svelte";
let props = $props();
// let searchQuery = $state({});
// let uploadErrors = $state({});
// let isChecking = $state({});
const formState = useForm({
schema: patientSchema,
initialForm: patientInitialForm,
defaultErrors: patientDefaultErrors,
mode: 'create',
modeOpt: 'cascade',
saveEndpoint: createPatient,
editEndpoint: null,
});
const helpers = usePatientForm(formState, patientSchema);
const handlers = {
clearForm: () => {
formState.reset();
}
};
const actions = getPatientFormActions(handlers);
let linkToDisplay = $derived(
Array.isArray(formState.form.LinkTo)
? formState.form.LinkTo
.map(p => p.PatientID)
.filter(Boolean)
.join(', ')
: ''
);
async function handleSave() {
const result = await formState.save();
if (result.status === 'success') {
console.log('Patient saved successfully');
props.masterDetail?.exitForm();
} else {
console.error('Failed to save patient');
}
}
// function getFilteredOptions(key) {
// const query = searchQuery[key] || "";
// if (!query) return formState.selectOptions[key] ?? [];
// return (formState.selectOptions[key] ?? []).filter(opt =>
// opt.label.toLowerCase().includes(query.toLowerCase())
// );
// }
// async function validateFieldAsync(field) {
// isChecking[field] = true;
// try {
// const asyncSchema = patientSchema.extend({
// PatientID: patientSchema.shape.PatientID.refine(
// async (value) => {
// if (!value) return false;
// const res = await fetch(`${API.BASE_URL}${API.CHECK}?PatientID=${value}`);
// const { status, data } = await res.json();
// return status === "success" && data === false ? false : true;
// },
// { message: "Patient ID already used" }
// )
// });
// const partial = asyncSchema.pick({ [field]: true });
// const result = await partial.safeParseAsync({ [field]: formState.form[field] });
// formState.errors[field] = result.success ? null : result.error.issues[0].message;
// } catch (err) {
// console.error('Async validation error:', err);
// } finally {
// isChecking[field] = false;
// }
// }
// function validateIdentifier() {
// const identifierType = formState.form.PatIdt.IdentifierType;
// const identifierValue = formState.form.PatIdt.Identifier;
// if (!identifierType || !identifierValue) {
// formState.errors['PatIdt.Identifier'] = null;
// return;
// }
// const schema = getIdentifierValidation(identifierType);
// const result = schema.safeParse(identifierValue);
// formState.errors['PatIdt.Identifier'] = result.success ? null : result.error.issues[0].message;
// }
// function getIdentifierValidation(identifierType) {
// switch (identifierType) {
// case 'KTP':
// return z.string()
// .length(16, "Must be 16 characters")
// .regex(/^$|^[0-9]+$/, "Can only contain numbers");
// case 'PASS':
// return z.string()
// .max(9, "Max 9 chars")
// .regex(/^[A-Z0-9]+$/, "Must be uppercase letters and numbers");
// case 'SSN':
// return z.string()
// .max(9, "Max 9 chars")
// .regex(/^$|^[0-9]+$/, "Can only contain numbers");
// case 'SIM':
// return z.string()
// .max(20, "Max 20 chars")
// .regex(/^$|^[0-9]+$/, "Can only contain numbers");
// case 'KTAS':
// return z.string()
// .max(11, "Max 11 chars")
// .regex(/^[A-Z0-9]+$/, "Must be uppercase letters and numbers");
// default:
// return z.string().min(1, "Identifier required");
// }
// }
// let hasErrors = $derived(
// Object.values(formState.errors).some(value => value !== null)
// );
function handleSaveAndOrder() {
console.log('save and order');
}
const primaryAction = $derived({
label: 'Save',
onClick: handleSave,
disabled: hasErrors || formState.isSaving.current,
loading: formState.isSaving.current
});
const secondaryActions = [
{
label: 'Save and Order',
onClick: handleSaveAndOrder
}
];
</script>
<!-- {#snippet Fieldset({ key, label, required, type, optionsEndpoint, options, validateOn, dependsOn, endpointParamKey })}
<div class="flex w-full flex-col gap-1.5">
<div class="flex justify-between items-center w-full">
<Label>{label}</Label>
{#if required}
<span class="text-destructive text-xl leading-none h-3.5">*</span>
{/if}
</div>
<div class="relative flex flex-col items-center w-full">
{#if type === "input" || type === "email" || type === "number"}
<Input
type={type === "number" ? "number" : "text"}
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes("input")) {
formState.validateField(key);
}
}}
onblur={() => {
if (validateOn?.includes("blur")) {
validateFieldAsync(key);
}
}}
/>
{:else if type === "date"}
<ReusableCalendar
bind:value={formState.form[key]}
parentFunction={(dateStr) => {
formState.form[key] = dateStr;
if (validateOn?.includes("input")) {
formState.validateField(key, dateStr, false);
}
}}
/>
{:else if type === "datetime"}
<ReusableCalendarTimepicker
bind:value={formState.form.TimeOfDeath}
/>
{:else if type === "textarea"}
<textarea
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
bind:value={formState.form[key]}
></textarea>
{:else if type === "select"}
{@const selectedLabel = formState.selectOptions[key]?.find(opt => opt.value === formState.form[key])?.label || "Choose"}
{@const filteredOptions = getFilteredOptions(key)}
<Select.Root type="single" bind:value={formState.form[key]}
onValueChange={(val) => {
formState.form[key] = val;
if (validateOn?.includes("input")) {
formState.validateField(key, formState.form[key], false);
}
if (key === "Province") {
formState.form.City = "";
formState.selectOptions.City = [];
formState.lastFetched.City = null;
}
}}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions(
{ key, optionsEndpoint, dependsOn, endpointParamKey },
formState.form
);
}
}}
>
<Select.Trigger class="w-full truncate">
{selectedLabel}
</Select.Trigger>
<Select.Content>
<div class="p-2">
<input
type="text"
placeholder="Search..."
class="w-full border rounded px-2 py-1 text-sm"
bind:value={searchQuery[key]}
/>
</div>
{#if formState.loadingOptions[key]}
<Select.Item disabled value="loading">Loading...</Select.Item>
{:else}
{#if !required}
<Select.Item value="">- None -</Select.Item>
{/if}
{#each filteredOptions as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
{:else if type === "identity"}
{@const selectedLabel = formState.selectOptions[key]?.find(opt => opt.value === formState.form.PatIdt.IdentifierType)?.label || "Choose"}
<div class="flex items-center w-full">
<Select.Root type="single" bind:value={formState.form.PatIdt.IdentifierType}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions({ key, optionsEndpoint});
}
}}
onValueChange={(val) => {
formState.form.PatIdt = {
IdentifierType: val,
Identifier:''
};
}}
>
<Select.Trigger class="w-full truncate text-muted-foreground rounded-r-none">
{selectedLabel}
</Select.Trigger>
<Select.Content>
{#if formState.loadingOptions[key]}
<Select.Item disabled value="loading">Loading...</Select.Item>
{:else}
{#if !required}
<Select.Item value="">- None -</Select.Item>
{/if}
{#each formState.selectOptions[key] ?? [] as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
<Input type="text" class="rounded-l-none" disabled={!formState.form.PatIdt.IdentifierType} bind:value={formState.form.PatIdt.Identifier} oninput={validateIdentifier} />
</div>
{:else if type === "custodian"}
<div class="flex items-center w-full">
<Input type="text" class="rounded-r-none" readonly bind:value={formState.form[key].PatientID} />
<CustodianModal {formState} mode="new"/>
</div>
{:else if type === "linkto"}
<div class="flex items-center w-full">
<Input
type="text"
class="rounded-r-none"
readonly
value={linkToDisplay}
placeholder="No linked patients"
/>
<LinktoModal {formState} />
</div>
{:else if type === "fileupload"}
<div class="flex flex-col w-full">
<ReusableUpload bind:attachments={formState.form[key]} bind:errors={uploadErrors}/>
{#if Object.keys(uploadErrors).length > 0}
<div class="flex flex-col justify-start text-destructive">
{#each Object.entries(uploadErrors) as [file, msg]}
<span>{msg}</span>
{/each}
</div>
{/if}
</div>
{:else}
<Input
type="text"
bind:value={formState.form[key]}
placeholder="Custom field type: {type}"
/>
{/if}
<div class="absolute top-8 min-h-[1rem] w-full">
{#if isChecking[key]}
<div class="flex items-center gap-1 mt-1">
<Spinner />
<span class="text-sm text-muted-foreground">Checking...</span>
</div>
{:else if formState.errors[key]}
<span class="text-destructive text-sm leading-none">
{formState.errors[key]}
</span>
{/if}
</div>
</div>
</div>
{/snippet} -->
<FormPageContainer title="Create Patient" {primaryAction} {secondaryActions}>
<PatientFormRenderer
bind:formState
formFields={patientFormFields}
bind:searchQuery={helpers.searchQuery}
bind:uploadErrors={helpers.uploadErrors}
bind:isChecking={helpers.isChecking}
linkToDisplay={helpers.linkToDisplay}
validateIdentifier={helpers.validateIdentifier}
mode="create"
/>
<!-- <div class="flex-1 min-h-0 overflow-y-auto p-2 space-y-6">
{#each patientFormFields as group}
<div class="space-y-6">
{#if group.title}
<div class="text-md 2xl:text-lg font-semibold italic">
<span class="border-b-2 border-primary">{group.title}</span>
</div>
{/if}
{#each group.rows as row}
<div
class="grid grid-cols-1 space-y-2 gap-6 md:gap-4"
class:md:grid-cols-1={row.columns.length === 1}
class:md:grid-cols-2={row.columns.length === 2}
class:md:grid-cols-3={row.columns.length === 3}
>
{#each row.columns as col}
{#if col.type === "group"}
<div
class="grid grid-cols-1 gap-6 md:gap-2"
class:md:grid-cols-1={col.columns.length === 1}
class:md:grid-cols-2={col.columns.length === 2}
class:md:grid-cols-3={col.columns.length === 3}
>
{#each col.columns as child}
{@render Fieldset(child)}
{/each}
</div>
{:else}
{@render Fieldset(col)}
{/if}
{/each}
</div>
{/each}
</div>
{/each}
</div> -->
</FormPageContainer>

View File

@ -0,0 +1,74 @@
<script>
import TopbarWrapper from "$lib/components/topbar/topbar-wrapper.svelte";
import { useForm } from "$lib/components/composable/use-form.svelte";
import { patientSchema, patientInitialForm, patientDefaultErrors, patientFormFields, getPatientFormActions } from "../config/patient-form-config";
import { createPatient } from "../api/patient-api";
import FormPageContainer from "../reusable/form-page-container.svelte";
import { usePatientForm } from "$lib/components/composable/use-patient-form.svelte";
import PatientFormRenderer from "../reusable/patient-form-renderer.svelte";
let props = $props();
let formState = useForm({
schema: patientSchema,
initialForm: patientInitialForm,
defaultErrors: patientDefaultErrors,
mode: 'create',
modeOpt: 'cascade',
saveEndpoint: createPatient,
editEndpoint: null,
});
const helpers = usePatientForm(formState, patientSchema);
const handlers = {
clearForm: () => {
formState.reset();
}
};
const actions = getPatientFormActions(handlers);
async function handleSave() {
const result = await formState.save();
if (result.status === 'success') {
console.log('Patient saved successfully');
props.masterDetail?.exitForm();
} else {
console.error('Failed to save patient');
}
}
function handleSaveAndOrder() {
console.log('save and order');
}
const primaryAction = $derived({
label: 'Save',
onClick: handleSave,
disabled: helpers.hasErrors || formState.isSaving.current,
loading: formState.isSaving.current
});
const secondaryActions = [
{
label: 'Save and Order',
onClick: handleSaveAndOrder
}
];
</script>
<FormPageContainer title="Create Patient" {primaryAction} {secondaryActions} {actions}>
<PatientFormRenderer
{formState}
formFields={patientFormFields}
uploadErrors={helpers.uploadErrors}
isChecking={helpers.isChecking}
linkToDisplay={helpers.linkToDisplay}
validateIdentifier={helpers.validateIdentifier}
validateFieldAsync={helpers.validateFieldAsync}
mode="create"
/>
</FormPageContainer>

View File

@ -0,0 +1,523 @@
<script>
import TopbarWrapper from "$lib/components/topbar/topbar-wrapper.svelte";
import { Button } from "$lib/components/ui/button/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
// import { API } from "$lib/config/api";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
// import EraserIcon from "@lucide/svelte/icons/eraser";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { useForm } from "$lib/components/composable/use-form.svelte";
import { patientSchema, patientInitialForm, patientDefaultErrors, patientFormFields, getPatientFormActions } from "../config/patient-form-config";
import { editPatient } from "../api/patient-api";
import * as Select from "$lib/components/ui/select/index.js";
import ReusableCalendar from "$lib/components/reusable/reusable-calendar.svelte";
import ReusableCalendarTimepicker from "$lib/components/reusable/reusable-calendar-timepicker.svelte";
import CustodianModal from "../modal/custodian-modal.svelte";
import LinktoModal from "../modal/linkto-modal.svelte";
import ReusableUpload from "$lib/components/reusable/reusable-upload.svelte";
import { API } from "$lib/config/api";
import { z } from "zod";
import { untrack } from "svelte";
import FormPageContainer from "../reusable/form-page-container.svelte";
let props = $props();
let searchQuery = $state({});
let uploadErrors = $state({});
let isChecking = $state({});
const actions = [];
const formState = useForm({
schema: patientSchema,
initialForm: patientInitialForm,
defaultErrors: {},
mode: 'edit',
modeOpt: 'cascade',
saveEndpoint: null,
editEndpoint: editPatient,
});
$effect(() => {
// if (props.masterDetail?.selectedItem?.patient) {
// formState.setForm(props.masterDetail.selectedItem.patient);
// }
const backendData = props.masterDetail?.selectedItem?.patient;
if (!backendData) return;
untrack(() => {
const formData = {
...backendData,
PatIdt: backendData.PatIdt ?? patientInitialForm.PatIdt,
LinkTo: backendData.LinkTo ?? [],
Custodian: backendData.Custodian ?? patientInitialForm.Custodian,
Sex: backendData.SexKey || backendData.Sex,
Religion: backendData.ReligionKey || backendData.Religion,
MaritalStatus: backendData.MaritalStatusKey || backendData.MaritalStatus,
Ethnic: backendData.EthnicKey || backendData.Ethnic,
Race: backendData.RaceKey || backendData.Race,
Country: backendData.CountryKey || backendData.Country,
DeathIndicator: backendData.DeathIndicatorKey || backendData.DeathIndicator,
Province: backendData.ProvinceID || backendData.Province,
City: backendData.CityID || backendData.City,
};
formState.setForm(formData);
// Jalankan fetch options hanya sekali saat inisialisasi data
patientFormFields.forEach(group => {
group.rows.forEach(row => {
row.columns.forEach(col => {
if (col.type === "group") {
col.columns.forEach(child => {
if (child.type === "select" && child.optionsEndpoint) {
formState.fetchOptions(child, formData);
}
});
} else if ((col.type === "select" || col.type === "identity") && col.optionsEndpoint) {
formState.fetchOptions(col, formData);
}
});
});
});
if (formData.Province && formData.City) {
formState.fetchOptions(
{
key: "City",
optionsEndpoint: `${API.BASE_URL}${API.CITY}`,
dependsOn: "Province",
endpointParamKey: "Parent"
},
formData
);
}
});
});
// $inspect(formState.selectOptions)
let linkToDisplay = $derived(
Array.isArray(formState.form.LinkTo)
? formState.form.LinkTo
.map(p => p.PatientID)
.filter(Boolean)
.join(', ')
: ''
);
async function handleEdit() {
const result = await formState.save();
if (result.status === 'success') {
console.log('Patient updated successfully');
props.masterDetail?.exitForm();
} else {
console.error('Failed to update patient:', result.message);
}
}
function getFilteredOptions(key) {
const query = searchQuery[key] || "";
if (!query) return formState.selectOptions[key] ?? [];
return (formState.selectOptions[key] ?? []).filter(opt =>
opt.label.toLowerCase().includes(query.toLowerCase())
);
}
async function validateFieldAsync(field) {
isChecking[field] = true;
try {
const asyncSchema = patientSchema.extend({
PatientID: patientSchema.shape.PatientID.refine(
async (value) => {
if (!value) return false;
const res = await fetch(`${API.BASE_URL}${API.CHECK}?PatientID=${value}`);
const { status, data } = await res.json();
return status === "success" && data === false ? false : true;
},
{ message: "Patient ID already used" }
)
});
const partial = asyncSchema.pick({ [field]: true });
const result = await partial.safeParseAsync({ [field]: formState.form[field] });
formState.errors[field] = result.success ? null : result.error.issues[0].message;
} catch (err) {
console.error('Async validation error:', err);
} finally {
isChecking[field] = false;
}
}
function validateIdentifier() {
const identifierType = formState.form.PatIdt.IdentifierType;
const identifierValue = formState.form.PatIdt.Identifier;
if (!identifierType || !identifierValue) {
formState.errors['PatIdt.Identifier'] = null;
return;
}
const schema = getIdentifierValidation(identifierType);
const result = schema.safeParse(identifierValue);
formState.errors['PatIdt.Identifier'] = result.success ? null : result.error.issues[0].message;
}
function getIdentifierValidation(identifierType) {
switch (identifierType) {
case 'KTP':
return z.string()
.max(16, "Max 16 chars")
.regex(/^$|^[0-9]+$/, "Can only contain numbers");
case 'PASS':
return z.string()
.max(9, "Max 9 chars")
.regex(/^[A-Z0-9]+$/, "Must be uppercase letters and numbers");
case 'SSN':
return z.string()
.max(9, "Max 9 chars")
.regex(/^$|^[0-9]+$/, "Can only contain numbers");
case 'SIM':
return z.string()
.max(20, "Max 20 chars")
.regex(/^$|^[0-9]+$/, "Can only contain numbers");
case 'KTAS':
return z.string()
.max(11, "Max 11 chars")
.regex(/^[A-Z0-9]+$/, "Must be uppercase letters and numbers");
default:
return z.string().min(1, "Identifier required");
}
}
let hasErrors = $derived(
Object.values(formState.errors).some(value => value !== null)
);
const primaryAction = $derived({
label: 'Edit',
onClick: handleEdit,
disabled: hasErrors || formState.isSaving.current,
loading: formState.isSaving.current
});
const secondaryActions = [];
</script>
{#snippet Fieldset({ key, label, required, type, optionsEndpoint, options, validateOn, dependsOn, endpointParamKey })}
<div class="flex w-full flex-col gap-1.5">
<div class="flex justify-between items-center w-full">
<Label>{label}</Label>
{#if required}
<span class="text-destructive text-xl leading-none h-3.5">*</span>
{/if}
</div>
<div class="relative flex flex-col items-center w-full">
{#if type === "input" || type === "email" || type === "number"}
<Input
type={type === "number" ? "number" : "text"}
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes("input")) {
formState.validateField(key);
}
}}
onblur={() => {
if (validateOn?.includes("blur")) {
validateFieldAsync(key);
}
}}
/>
{:else if type === "date"}
<ReusableCalendar
bind:value={formState.form[key]}
parentFunction={(dateStr) => {
formState.form[key] = dateStr;
if (validateOn?.includes("input")) {
formState.validateField(key, dateStr, false);
}
}}
/>
{:else if type === "datetime"}
<!-- <ReusableCalendarTimepicker
bind:value={formState.form.TimeOfDeath}
parentFunction={(val) => {
formState.validateField('TimeOfDeath');
}}
/> -->
<ReusableCalendarTimepicker
bind:value={formState.form.TimeOfDeath}
/>
{:else if type === "textarea"}
<textarea
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
bind:value={formState.form[key]}
></textarea>
{:else if type === "select"}
{@const selectedLabel = formState.selectOptions[key]?.find(opt => opt.value === formState.form[key])?.label || "Choose"}
{@const filteredOptions = getFilteredOptions(key)}
<Select.Root type="single" bind:value={formState.form[key]}
onValueChange={(val) => {
formState.form[key] = val;
if (validateOn?.includes("input")) {
formState.validateField(key, formState.form[key], false);
}
if (key === "Province") {
formState.form.City = "";
formState.selectOptions.City = [];
formState.lastFetched.City = null;
}
}}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions({ key, optionsEndpoint, dependsOn, endpointParamKey}, formState.form );
}
}}
>
<Select.Trigger class="w-full truncate">
{selectedLabel}
</Select.Trigger>
<Select.Content>
<div class="p-2">
<input
type="text"
placeholder="Search..."
class="w-full border rounded px-2 py-1 text-sm"
bind:value={searchQuery[key]}
/>
</div>
{#if formState.loadingOptions[key]}
<Select.Item disabled value="loading">Loading...</Select.Item>
{:else}
{#if !required}
<Select.Item value="">- None -</Select.Item>
{/if}
{#each filteredOptions as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
{:else if type === "identity"}
{@const selectedLabel = formState.selectOptions[key]?.find(opt => opt.value === formState.form.PatIdt.IdentifierType)?.label || "Choose"}
<div class="flex items-center w-full">
<Select.Root type="single" bind:value={formState.form.PatIdt.IdentifierType}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions({ key, optionsEndpoint});
}
}}
onValueChange={(val) => {
formState.form.PatIdt = {
IdentifierType: val,
Identifier:''
};
}}
>
<Select.Trigger class="w-full truncate text-muted-foreground rounded-r-none">
{selectedLabel}
</Select.Trigger>
<Select.Content>
{#if formState.loadingOptions[key]}
<Select.Item disabled value="loading">Loading...</Select.Item>
{:else}
{#if !required}
<Select.Item value="">- None -</Select.Item>
{/if}
{#each formState.selectOptions[key] ?? [] as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
<Input type="text" class="rounded-l-none" disabled={!formState.form.PatIdt.IdentifierType} bind:value={formState.form.PatIdt.Identifier} oninput={validateIdentifier} />
</div>
{:else if type === "custodian"}
<div class="flex items-center w-full">
<Input type="text" class="rounded-r-none" readonly bind:value={formState.form[key].PatientID} />
<CustodianModal {formState} mode="new"/>
</div>
{:else if type === "linkto"}
<div class="flex items-center w-full">
<Input
type="text"
class="rounded-r-none"
readonly
value={linkToDisplay}
placeholder="No linked patients"
/>
<LinktoModal {formState} />
</div>
{:else if type === "fileupload"}
<div class="flex flex-col w-full">
<ReusableUpload bind:attachments={formState.form[key]} bind:errors={uploadErrors}/>
{#if Object.keys(uploadErrors).length > 0}
<div class="flex flex-col justify-start text-destructive">
{#each Object.entries(uploadErrors) as [file, msg]}
<span>{msg}</span>
{/each}
</div>
{/if}
</div>
{:else}
<Input
type="text"
bind:value={formState.form[key]}
placeholder="Custom field type: {type}"
/>
{/if}
<div class="absolute top-8 min-h-[1rem] w-full">
<!-- {#if formState.errors[key]}
<span class="text-destructive text-sm leading-none">
{formState.errors[key]}
</span>
{/if} -->
{#if isChecking[key]}
<div class="flex items-center gap-1 mt-1">
<Spinner />
<span class="text-sm text-muted-foreground">Checking...</span>
</div>
{:else if formState.errors[key]}
<span class="text-destructive text-sm leading-none">
{formState.errors[key]}
</span>
{/if}
</div>
</div>
</div>
{/snippet}
<FormPageContainer title="Edit Patient" {primaryAction} {secondaryActions}>
<div class="flex-1 min-h-0 overflow-y-auto p-2 space-y-6">
{#each patientFormFields as group}
<div class="space-y-6">
{#if group.title}
<div class="text-md 2xl:text-lg font-semibold italic">
<span class="border-b-2 border-primary">{group.title}</span>
</div>
{/if}
{#each group.rows as row}
<div
class="grid grid-cols-1 space-y-2 gap-6 md:gap-4"
class:md:grid-cols-1={row.columns.length === 1}
class:md:grid-cols-2={row.columns.length === 2}
class:md:grid-cols-3={row.columns.length === 3}
>
{#each row.columns as col}
{#if col.type === "group"}
<div
class="grid grid-cols-1 gap-6 md:gap-2"
class:md:grid-cols-1={col.columns.length === 1}
class:md:grid-cols-2={col.columns.length === 2}
class:md:grid-cols-3={col.columns.length === 3}
>
{#each col.columns as child}
{@render Fieldset(child)}
{/each}
</div>
{:else}
{@render Fieldset(col)}
{/if}
{/each}
</div>
{/each}
</div>
{/each}
</div>
</FormPageContainer>
<!-- <div class="flex flex-col p-2 gap-4 h-full w-full">
<TopbarWrapper {actions} title="Edit Patient"/>
<div class="flex-1 min-h-0 overflow-y-auto p-2 space-y-6">
{#each patientFormFields as group}
<div class="space-y-6">
{#if group.title}
<div class="text-md 2xl:text-lg font-semibold italic">
<span class="border-b-2 border-primary">{group.title}</span>
</div>
{/if}
{#each group.rows as row}
<div
class="grid grid-cols-1 space-y-2 gap-6 md:gap-4"
class:md:grid-cols-1={row.columns.length === 1}
class:md:grid-cols-2={row.columns.length === 2}
class:md:grid-cols-3={row.columns.length === 3}
>
{#each row.columns as col}
{#if col.type === "group"}
<div
class="grid grid-cols-1 gap-6 md:gap-2"
class:md:grid-cols-1={col.columns.length === 1}
class:md:grid-cols-2={col.columns.length === 2}
class:md:grid-cols-3={col.columns.length === 3}
>
{#each col.columns as child}
{@render Fieldset(child)}
{/each}
</div>
{:else}
{@render Fieldset(col)}
{/if}
{/each}
</div>
{/each}
</div>
{/each}
</div>
<div class="mt-auto flex justify-end items-center pt-2">
<Button
size="sm"
class="rounded-r-none cursor-pointer"
disabled={hasErrors || formState.isSaving.current}
onclick={handleSave}
>
{#if formState.isSaving.current}
<Spinner />
{:else}
Save
{/if}
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button
size="icon"
class="size-8 rounded-l-none"
disabled={hasErrors || formState.isSaving.current}
>
<ChevronUpIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content collisionPadding={8}>
<DropdownMenu.Group>
<DropdownMenu.Item onclick={handleSaveAndOrder}>
Save and Order
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleSave}>
Save
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div> -->

View File

@ -0,0 +1,63 @@
<script>
import { columns } from "$lib/components/patient/table/colums";
import TopbarWrapper from "$lib/components/topbar/topbar-wrapper.svelte";
import ReusableSearchParam from "$lib/components/reusable/reusable-search-param.svelte";
import ReusableEmpty from "$lib/components/reusable/reusable-empty.svelte";
import { searchParam } from "$lib/components/patient/api/patient-api";
import ReusableDataTable from "$lib/components/reusable/reusable-data-table.svelte";
import { useSearch } from "$lib/components/composable/useSearch.svelte";
import { searchFields, patientActions } from "../config/patient-config";
let props = $props();
const search = useSearch(searchFields, searchParam);
const actions = patientActions(props.masterDetail)
actions.find(a => a.label === 'Search Parameters').popoverContent = searchParamSnippet;
let activeRowId = $state(null);
</script>
{#snippet searchParamSnippet()}
<ReusableSearchParam {searchFields} bind:searchQuery={search.searchQuery} onSearch={search.handleSearch} onReset={search.handleReset} isLoading={search.isLoading}/>
{/snippet}
<div
role="button"
tabindex="0"
onclick={() => props.masterDetail.isFormMode && props.masterDetail.exitForm()}
onkeydown={(e) => e.key === 'Enter' && props.masterDetail.isFormMode && props.masterDetail.exitForm()}
class={`
${props.masterDetail.isMobile ? "w-full" : props.masterDetail.isFormMode ? "w-[3%] cursor-pointer" : "w-[35%]"}
transition-all duration-300 flex flex-col items-center p-2 h-full overflow-y-auto
`}
>
<div class={`flex w-full ${props.masterDetail.isFormMode ? "flex-col justify-center h-full items-center" : "flex-col justify-start h-full"}`} >
{#if props.masterDetail.isFormMode}
<span class="flex flex-col items-center justify-center gap-4 tracking-widest font-semibold select-none">
{#each "PATIENT".split("") as c}
<span class="leading-none">{c}</span>
{/each}
</span>
{/if}
{#if !props.masterDetail.isFormMode}
<div role="button" tabindex="0" class="flex flex-1 flex-col" onclick={(e) => e.stopPropagation()} onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
}
}}>
<TopbarWrapper {actions}/>
<div class="flex-1 w-full h-full">
{#if search.searchData.length > 0}
<ReusableDataTable data={search.searchData} columns={columns} handleRowClick={props.masterDetail.select} {activeRowId} rowIdKey="InternalPID"/>
{:else}
<div class="flex h-full">
<ReusableEmpty desc="Try searching from search parameters"/>
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>

View File

@ -0,0 +1,123 @@
<script>
import TopbarWrapper from "$lib/components/topbar/topbar-wrapper.svelte";
import { formatUTCDate } from "$lib/utils/formatUTCDate";
import { getUrl } from "$lib/utils/getUrl";
import { detailSections, viewActions } from "../config/patient-config";
import ReusableEmpty from "$lib/components/reusable/reusable-empty.svelte";
let props = $props();
let patient = $derived(props.masterDetail?.selectedItem?.patient);
const handlers = {
orderLab: () => console.log('order lab'),
medicalRecord: () => console.log('medical record'),
auditPatient: () => console.log('audit patient'),
editPatient: () => props.masterDetail.enterEdit(),
};
const actions = viewActions(handlers);
let fullName = $derived.by(() => {
if (!patient) return "";
const { NameFirst = "", NameMiddle = "", NameLast = "" } = patient;
return `${NameFirst} ${NameMiddle} ${NameLast}`.replace(/\s+/g, ' ').trim();
});
let prefix = $derived(
props.masterDetail?.selectedItem?.patient?.Prefix || null
);
function getFieldValue(field) {
if (!patient) return "-";
if (field.keys) {
return field.keys
.map(k => field.parentKey ? patient[field.parentKey]?.[k] : patient[k])
.filter(val => val && val.trim() !== "")
.join(" / ");
}
return field.parentKey ? patient[field.parentKey]?.[field.key] : patient[field.key];
}
</script>
{#snippet Fieldset({ value, label, className = '', isUTCDate = false, isFileList = false })}
<div class={`space-y-1.5 ${className}`}>
<dt class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{label}
</dt>
<dd class="text-sm font-medium">
{#if isFileList && Array.isArray(value)}
<div class="flex flex-col gap-1">
{#each value as item}
<div class="flex gap-2 items-center">
<a href={getUrl(item.Address)} target="_blank">
<div
class="w-10 h-10 object-cover border p-1 border-dashed border-2 border-primary rounded flex justify-center items-center"
>
{#if item.Address.endsWith(".jpg") || item.Address.endsWith(".png") || item.Address.endsWith(".PNG")}
<img
src={getUrl(item.Address)}
alt="preview"
class="max-w-full max-h-full object-cover"
/>
{:else}
<i class="fa-solid fa-file fa-xl"></i>
{/if}
</div>
</a>
<span class="text-xs break-all">{item.Address.split("/").pop()}</span>
</div>
{/each}
</div>
{:else if Array.isArray(value)}
{#each value as item, i}
<span>{item.PatientID || item.Identifier || JSON.stringify(item)}{i < value.length - 1 ? ', ' : ''}</span>
{/each}
{:else if isUTCDate}
{formatUTCDate(value)}
{:else}
{value ?? "-"}
{/if}
</dd>
</div>
{/snippet}
{#if props.masterDetail.selectedItem}
<div class="flex flex-col px-2 py-1 gap-2 h-full w-full">
<TopbarWrapper title={fullName} subtitle={prefix} {actions} />
<div class="flex-1 min-h-0 overflow-y-auto space-y-4">
{#each detailSections as section}
<div class="p-4">
<div class={section.class}>
{#each section.fields as field}
{#if field.isGroup}
<div class={field.class}>
{#each field.fields as subField}
{@render Fieldset({
label: subField.label,
value: getFieldValue(subField)
})}
{/each}
</div>
{:else}
{@render Fieldset({
label: field.label,
value: getFieldValue(field),
className: field.className,
isUTCDate: field.isUTCDate,
isFileList: field.isFileList
})}
{/if}
{/each}
</div>
</div>
{/each}
</div>
</div>
{:else}
<ReusableEmpty desc="Select a patient to see details"/>
{/if}

View File

@ -0,0 +1,82 @@
<script>
import TopbarWrapper from "$lib/components/topbar/topbar-wrapper.svelte";
import { Button } from "$lib/components/ui/button/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Spinner } from "$lib/components/ui/spinner/index.js";
let {
title,
primaryAction,
secondaryActions,
actions,
children
} = $props();
</script>
<div class="flex flex-col p-2 gap-4 h-full w-full">
<TopbarWrapper actions={actions} title={title}/>
{@render children()}
<div class="mt-auto flex justify-end items-center pt-2">
<Button
size="sm"
class="cursor-pointer {secondaryActions.length ? 'rounded-r-none' : ''}"
disabled={primaryAction.disabled}
onclick={primaryAction.onClick}
>
{#if primaryAction.loading}
<Spinner />
{:else}
{primaryAction.label}
{/if}
</Button>
{#if secondaryActions.length}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button
size="icon"
class="size-8 rounded-l-none"
disabled={primaryAction.disabled}
>
<ChevronUpIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content collisionPadding={8}>
<DropdownMenu.Group>
{#each secondaryActions as action}
<DropdownMenu.Item
disabled={action.disabled}
onclick={action.onClick}
>
{action.label}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
<!-- <DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button
size="icon"
class="size-8 rounded-l-none"
disabled={props.hasErrors || props.primaryAction.formState.isSaving.current}
>
<ChevronUpIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content collisionPadding={8}>
<DropdownMenu.Group>
<DropdownMenu.Item>
Save and Order
</DropdownMenu.Item>
<DropdownMenu.Item onclick={props.handleSave}>
Save
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root> -->
</div>
</div>

View File

@ -0,0 +1,334 @@
<script>
import { Button } from "$lib/components/ui/button/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import ReusableCalendar from "$lib/components/reusable/reusable-calendar.svelte";
import ReusableCalendarTimepicker from "$lib/components/reusable/reusable-calendar-timepicker.svelte";
import CustodianModal from "../modal/custodian-modal.svelte";
import LinktoModal from "../modal/linkto-modal.svelte";
import ReusableUpload from "$lib/components/reusable/reusable-upload.svelte";
let {
formState,
formFields,
uploadErrors,
isChecking,
linkToDisplay,
validateIdentifier,
validateFieldAsync,
mode = 'create'
} = $props();
let searchQuery = $state({});
function getFilteredOptions(key) {
const query = searchQuery[key] || "";
if (!query) return formState.selectOptions[key] ?? [];
return (formState.selectOptions[key] ?? []).filter(opt =>
opt.label.toLowerCase().includes(query.toLowerCase())
);
}
$effect(() => {
initializeDefaultValues();
});
async function initializeDefaultValues() {
console.log('object');
for (const group of formFields) {
for (const row of group.rows) {
for (const col of row.columns) {
if (col.type === "group") {
for (const child of col.columns) {
await handleDefaultValue(child);
}
} else {
await handleDefaultValue(col);
}
}
}
}
}
async function handleDefaultValue(field) {
if (!field.defaultValue || !field.optionsEndpoint) return;
// Fetch options
await formState.fetchOptions(field, formState.form);
// Set default jika form masih kosong
if (!formState.form[field.key]) {
formState.form[field.key] = field.defaultValue;
}
}
</script>
{#snippet Fieldset({ key, label, required, type, optionsEndpoint, options, validateOn, dependsOn, endpointParamKey })}
<div class="flex w-full flex-col gap-1.5">
<div class="flex justify-between items-center w-full">
<Label>{label}</Label>
{#if required}
<span class="text-destructive text-xl leading-none h-3.5">*</span>
{/if}
</div>
<div class="relative flex flex-col items-center w-full">
{#if type === "text"}
<Input
type="text"
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes("input")) {
formState.validateField(key, formState.form[key], false);
}
}}
onblur={() => {
if (validateOn?.includes("blur")) {
validateFieldAsync(key);
}
}}
/>
{:else if type === "email"}
<Input
type="email"
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes("input")) {
formState.validateField(key, formState.form[key], false);
}
}}
onblur={() => {
if (validateOn?.includes("blur")) {
formState.validateField(key, formState.form[key], false);
}
}}
/>
{:else if type === "number"}
<Input
type="number"
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes("input")) {
formState.validateField(key, formState.form[key], false);
}
}}
onblur={() => {
if (validateOn?.includes("blur")) {
formState.validateField(key, formState.form[key], false);
}
}}
/>
{:else if type === "date"}
<ReusableCalendar
bind:value={formState.form[key]}
parentFunction={(dateStr) => {
formState.form[key] = dateStr;
if (validateOn?.includes("input")) {
formState.validateField(key, dateStr, false);
}
}}
/>
{:else if type === "datetime"}
<ReusableCalendarTimepicker
bind:value={formState.form[key]}
onValueChange={(val) => {
formState.form[key] = val;
if (validateOn?.includes("input")) {
formState.validateField(key, formState.form[key], false);
}
}}
/>
{:else if type === "textarea"}
<textarea
class="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
oninput={() => {
if (validateOn?.includes("input")) {
formState.validateField(key, formState.form[key], false);
}
}}
onblur={() => {
if (validateOn?.includes("blur")) {
formState.validateField(key, formState.form[key], false);
}
}}
bind:value={formState.form[key]}
></textarea>
{:else if type === "select"}
{@const selectedLabel = formState.selectOptions[key]?.find(opt => opt.value === formState.form[key])?.label || "Choose"}
{@const filteredOptions = getFilteredOptions(key)}
<Select.Root type="single" bind:value={formState.form[key]}
onValueChange={(val) => {
formState.form[key] = val;
if (validateOn?.includes("input")) {
formState.validateField(key, formState.form[key], false);
}
if (key === "Province") {
formState.form.City = "";
formState.selectOptions.City = [];
formState.lastFetched.City = null;
}
}}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions(
{ key, optionsEndpoint, dependsOn, endpointParamKey },
formState.form
);
}
}}
>
<Select.Trigger class="w-full truncate">
{selectedLabel}
</Select.Trigger>
<Select.Content>
<div class="p-2">
<input
type="text"
placeholder="Search..."
class="w-full border rounded px-2 py-1 text-sm"
bind:value={searchQuery[key]}
/>
</div>
{#if formState.loadingOptions[key]}
<Select.Item disabled value="loading">Loading...</Select.Item>
{:else}
{#if !required}
<Select.Item value="">- None -</Select.Item>
{/if}
{#each filteredOptions as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
{:else if type === "identity"}
{@const selectedLabel = formState.selectOptions[key]?.find(opt => opt.value === formState.form.PatIdt.IdentifierType)?.label || "Choose"}
<div class="flex items-center w-full">
<Select.Root type="single" bind:value={formState.form.PatIdt.IdentifierType}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions({ key, optionsEndpoint});
}
}}
onValueChange={(val) => {
formState.form.PatIdt = {
IdentifierType: val,
Identifier:''
};
}}
>
<Select.Trigger class="w-full truncate text-muted-foreground rounded-r-none">
{selectedLabel}
</Select.Trigger>
<Select.Content>
{#if formState.loadingOptions[key]}
<Select.Item disabled value="loading">Loading...</Select.Item>
{:else}
{#if !required}
<Select.Item value="">- None -</Select.Item>
{/if}
{#each formState.selectOptions[key] ?? [] as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
<Input type="text" class="rounded-l-none" disabled={!formState.form.PatIdt.IdentifierType} bind:value={formState.form.PatIdt.Identifier} oninput={validateIdentifier} />
</div>
{:else if type === "custodian"}
<div class="flex items-center w-full">
<Input type="text" class="rounded-r-none" readonly bind:value={formState.form[key].PatientID} />
<CustodianModal {formState} mode="new"/>
</div>
{:else if type === "linkto"}
<div class="flex items-center w-full">
<Input
type="text"
class="rounded-r-none"
readonly
value={linkToDisplay}
placeholder="No linked patients"
/>
<LinktoModal {formState} />
</div>
{:else if type === "fileupload"}
<div class="flex flex-col w-full">
<ReusableUpload attachments={formState.form[key]} errors={uploadErrors}/>
{#if Object.keys(uploadErrors).length > 0}
<div class="flex flex-col justify-start text-destructive">
{#each Object.entries(uploadErrors) as [file, msg]}
<span>{msg}</span>
{/each}
</div>
{/if}
</div>
{:else}
<Input
type="text"
bind:value={formState.form[key]}
placeholder="Custom field type: {type}"
/>
{/if}
<div class="absolute top-8 min-h-[1rem] w-full">
{#if isChecking[key]}
<div class="flex items-center gap-1 mt-1">
<Spinner />
<span class="text-sm text-muted-foreground">Checking...</span>
</div>
{:else if formState.errors[key]}
<span class="text-destructive text-sm leading-none">
{formState.errors[key]}
</span>
{/if}
</div>
</div>
</div>
{/snippet}
<div class="flex-1 min-h-0 overflow-y-auto p-2 space-y-6">
{#each formFields as group}
<div class="space-y-6">
{#if group.title}
<div class="text-md 2xl:text-lg font-semibold italic">
<span class="border-b-2 border-primary">{group.title}</span>
</div>
{/if}
{#each group.rows as row}
<div
class="grid grid-cols-1 space-y-2 gap-6 md:gap-4"
class:md:grid-cols-1={row.columns.length === 1}
class:md:grid-cols-2={row.columns.length === 2}
class:md:grid-cols-3={row.columns.length === 3}
>
{#each row.columns as col}
{#if col.type === "group"}
<div
class="grid grid-cols-1 gap-6 md:gap-2"
class:md:grid-cols-1={col.columns.length === 1}
class:md:grid-cols-2={col.columns.length === 2}
class:md:grid-cols-3={col.columns.length === 3}
>
{#each col.columns as child}
{@render Fieldset(child)}
{/each}
</div>
{:else}
{@render Fieldset(col)}
{/if}
{/each}
</div>
{/each}
</div>
{/each}
</div>

View File

@ -0,0 +1,23 @@
export const columns = [
{
accessorKey: "PatientID",
header: "PatientID",
},
{
accessorKey: "FullName",
header: "Patient Name",
},
{
accessorKey: "SexLabel",
header: "Sex",
},
{
accessorKey: "Birthdate",
header: "Birthdate",
cell: ({ getValue }) => {
const value = getValue();
if (!value) return "";
return value.split(' ')[0];
}
},
];

View File

@ -0,0 +1,196 @@
<script>
import Calendar from "$lib/components/ui/calendar/calendar.svelte";
import * as Popover from "$lib/components/ui/popover/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { getLocalTimeZone, fromDate, today, parseDate } from "@internationalized/date";
import Clock2Icon from "@lucide/svelte/icons/clock-2";
// const id = $props.id();
// let { title, parentFunction, initialValue } = $props();
// let value = $state('');
// let calendarValue = $state(null);
// let timeValue = $state("00:00:00");
// let open = $state(false);
const id = $props.id();
let { title, parentFunction, value = $bindable("") } = $props();
let open = $state(false);
let calendarValue = $state();
let timeValue = $state("00:00:00");
$effect(() => {
if (value && typeof value === "string") {
try {
const dt = new Date(value);
calendarValue = fromDate(dt, getLocalTimeZone());
timeValue = dt.toLocaleTimeString("sv-SE", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch (err) {
calendarValue = undefined;
timeValue = "00:00:00";
}
} else {
calendarValue = undefined;
timeValue = "00:00:00";
}
});
function handleChange() {
if (!calendarValue) {
value = "";
parentFunction?.("");
return;
}
const [h, m, s] = timeValue.split(":").map(Number);
const dt = calendarValue.toDate(getLocalTimeZone());
dt.setHours(h, m, s);
const isoString = dt.toISOString();
value = isoString;
parentFunction?.(isoString);
}
function formatDateTime(val) {
if (!val) return "Select date and time";
const dt = new Date(val);
return dt.toLocaleString("sv-SE");
}
// function updateDateTime() {
// if (!calendarValue) return;
// const [h, m, s] = timeValue.split(":").map(Number);
// const dt = calendarValue.toDate(getLocalTimeZone());
// dt.setHours(h, m, s);
// value = dt.toISOString();
// parentFunction?.(value);
// }
// function formatDateTime(val) {
// if (!val) return "Select date";
// const dt = new Date(val);
// return dt.toLocaleString("sv-SE");
// }
// $effect(() => {
// if (initialValue) {
// const dt = new Date(initialValue);
// calendarValue = fromDate(dt, getLocalTimeZone());
// timeValue = dt.toLocaleTimeString("sv-SE", {
// hour12: false,
// hour: "2-digit",
// minute: "2-digit",
// second: "2-digit",
// });
// value = initialValue;
// }
// });
</script>
<div class="flex flex-col w-full">
{#if title}
<Label for="{id}-datetime">{title}</Label>
{/if}
<Popover.Root bind:open>
<Popover.Trigger id="{id}-datetime">
{#snippet child({ props })}
<Button
{...props}
variant="outline"
class="w-full justify-between font-normal text-muted-foreground truncate"
>
{formatDateTime(value)}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-auto overflow-hidden p-0" align="start">
<div class="px-1">
<Calendar
type="single"
bind:value={calendarValue}
captionLayout="dropdown"
onValueChange={handleChange}
maxValue={today(getLocalTimeZone())}
/>
</div>
<div class="flex flex-col gap-6 border-t p-4">
<div class="flex w-full flex-col gap-3">
<Label for="time-input">Time</Label>
<div class="relative flex w-full items-center gap-2">
<Clock2Icon
class="text-muted-foreground pointer-events-none absolute left-2.5 size-4 select-none"
/>
<Input
id="time-input"
type="time"
step="1"
bind:value={timeValue}
oninput={handleChange}
class="appearance-none pl-8 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
</div>
</div>
</Popover.Content>
</Popover.Root>
</div>
<!-- <div class="flex flex-col gap-1.5 w-full">
{#if title}
<Label for="{id}-date">{title}</Label>
{/if}
<Popover.Root bind:open>
<Popover.Trigger id="{id}-date" >
{#snippet child({ props })}
<Button
{...props}
variant="outline"
class="w-full justify-between font-normal text-xs truncate 2xl:text-base px-2"
>
{formatDateTime(value, timeValue)}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-auto overflow-hidden p-0" align="start">
<Card.Root class="w-fit py-1">
<Card.Content class="px-1">
<Calendar
type="single"
bind:value={calendarValue}
captionLayout="dropdown"
onValueChange={updateDateTime}
maxValue={today(getLocalTimeZone())}
/>
</Card.Content>
<Card.Footer class="flex flex-col gap-6 border-t p-4">
<div class="flex w-full flex-col gap-3">
<Label for="time-from">Time</Label>
<div class="relative flex w-full items-center gap-2">
<Clock2Icon
class="text-muted-foreground pointer-events-none absolute left-2.5 size-4 select-none"
/>
<Input
id="time-from"
type="time"
step="1"
bind:value={timeValue}
oninput={updateDateTime}
class="appearance-none pl-8 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
</div>
</Card.Footer>
</Card.Root>
</Popover.Content>
</Popover.Root>
</div> -->

View File

@ -0,0 +1,71 @@
<script>
import Calendar from "$lib/components/ui/calendar/calendar.svelte";
import * as Popover from "$lib/components/ui/popover/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { getLocalTimeZone, today, parseDate } from "@internationalized/date";
const id = $props.id();
let { title, parentFunction, value = $bindable("") } = $props();
let open = $state(false);
let calendarValue = $state();
$effect(() => {
if (value && typeof value === "string") {
try {
calendarValue = parseDate(value);
} catch (err) {
calendarValue = undefined;
}
} else {
calendarValue = undefined;
}
});
function handleChange() {
open = false;
if (!calendarValue) {
value = "";
parentFunction?.("");
return;
}
const y = String(calendarValue.year).padStart(4, "0");
const m = String(calendarValue.month).padStart(2, "0");
const d = String(calendarValue.day).padStart(2, "0");
const dateStr = `${y}-${m}-${d}`;
value = dateStr;
parentFunction?.(dateStr);
}
</script>
<div class="flex flex-col gap-1.5 w-full">
{#if title}
<Label for="{id}-date">{title}</Label>
{/if}
<Popover.Root bind:open>
<Popover.Trigger id="{id}-date" >
{#snippet child({ props })}
<Button
{...props}
variant="outline"
class="w-full justify-between font-normal text-muted-foreground"
>
{value || "Select date"}
<ChevronDownIcon />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-auto overflow-hidden p-0" align="start">
<Calendar
type="single"
bind:value={calendarValue}
captionLayout="dropdown"
onValueChange={handleChange}
maxValue={today(getLocalTimeZone())}
/>
</Popover.Content>
</Popover.Root>
</div>

View File

@ -0,0 +1,187 @@
<script>
import { getCoreRowModel, getPaginationRowModel, getFilteredRowModel } from "@tanstack/table-core";
import {
createSvelteTable,
FlexRender,
} from "$lib/components/ui/data-table/index.js";
import * as Table from "$lib/components/ui/table/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
import ChevronsRightIcon from "@lucide/svelte/icons/chevrons-right";
import ChevronsLeftIcon from "@lucide/svelte/icons/chevrons-left";
import { Input } from "$lib/components/ui/input/index.js";
let props = $props();
let pagination = $state({ pageIndex: 0, pageSize: 10 });
let columnFilters = $state([]);
let globalFilter = $state("");
let activeRowId = $state(null);
let table = createSvelteTable({
get data() {
return props.data;
},
get columns() {
return props.columns;
},
state: {
get pagination() {
return pagination;
},
get columnFilters() {
return columnFilters;
},
get globalFilter() {
return globalFilter;
},
},
onPaginationChange: (updater) => {
if (typeof updater === "function") {
pagination = updater(pagination);
} else {
pagination = updater;
}
},
onColumnFiltersChange: (updater) => {
if (typeof updater === "function") {
columnFilters = updater(columnFilters);
} else {
columnFilters = updater;
}
},
onGlobalFilterChange: (value) => {
globalFilter = value;
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
</script>
<div class="h-full flex flex-col relative">
<div class="flex items-center absolute top-[-2.5rem]">
<Input
placeholder="Filter all columns..."
value={globalFilter}
oninput={(e) => {
globalFilter = e.currentTarget.value;
}}
class="h-8 w-64 text-xs px-2"
/>
</div>
<div class="rounded-md border h-full flex flex-col">
<Table.Root>
<Table.Header>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head colspan={header.colSpan}>
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
</Table.Head>
{/each}
</Table.Row>
{/each}
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row
data-state={row.getIsSelected() && "selected"}
onclick={() => props.handleRowClick(row.original)}
class="cursor-pointer"
>
{#each row.getVisibleCells() as cell, i (cell.id)}
<Table.Cell class={`${i === 0 ? "border-l-4" : ""} ${i === 0 && activeRowId == row.original[props.rowIdKey] ? "border-primary" : "border-transparent"}`}>
<FlexRender
content={cell.column.columnDef.cell}
context={cell.getContext()}
/>
</Table.Cell>
{/each}
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={props.columns.length} class="h-24 text-center">
No results.
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<div class="flex items-center justify-between p-2 mt-auto">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">Rows per page</p>
<Select.Root
allowDeselect={false}
type="single"
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<Select.Trigger class="h-8 w-[70px]">
{String(table.getState().pagination.pageSize)}
</Select.Trigger>
<Select.Content side="top">
{#each [1, 2, 3, 4, 5] as pageSize (pageSize)}
<Select.Item value={`${pageSize}`}>
{pageSize}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex items-center gap-2">
<div class="flex w-[100px] items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of
{table.getPageCount()}
</div>
<div class="flex items-center space-x-2">
<Button
variant="outline"
class="hidden size-8 p-0 lg:flex"
onclick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span class="sr-only">Go to first page</span>
<ChevronsLeftIcon />
</Button>
<Button
variant="outline"
class="size-8 p-0"
onclick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span class="sr-only">Go to previous page</span>
<ChevronLeftIcon />
</Button>
<Button
variant="outline"
class="size-8 p-0"
onclick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span class="sr-only">Go to next page</span>
<ChevronRightIcon />
</Button>
<Button
variant="outline"
class="hidden size-8 p-0 lg:flex"
onclick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span class="sr-only">Go to last page</span>
<ChevronsRightIcon />
</Button>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,19 @@
<script>
import * as Empty from "$lib/components/ui/empty/index.js";
import MehIcon from "@lucide/svelte/icons/meh";
let props = $props();
const Icon = props.icon || MehIcon;
</script>
<Empty.Root>
<Empty.Header>
<Empty.Media variant="icon">
{#if Icon}
<Icon class="size-8" />
{/if}
</Empty.Media>
<Empty.Title>{props.title ?? "No Data"}</Empty.Title>
<Empty.Description>{props.desc ?? "No Data"}</Empty.Description>
</Empty.Header>
</Empty.Root>

View File

@ -0,0 +1,53 @@
<script>
import { Button } from "$lib/components/ui/button/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import ReusableCalendar from "$lib/components/reusable/reusable-calendar.svelte";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import * as Select from "$lib/components/ui/select/index.js";
let props = $props();
</script>
<div class="w-full">
<div class="space-y-2">
{#each props.searchFields as field}
{#if field.type === "text"}
<div class="space-y-2">
<Label for={field.key}>{field.label}</Label>
<Input type="text" id={field.key} placeholder={field.placeholder} bind:value={props.searchQuery[field.key]} autocomplete=off/>
</div>
{:else if field.type === "date"}
<div class="space-y-2">
<ReusableCalendar title={field.label} bind:value={props.searchQuery[field.key]}/>
</div>
{:else if field.type === "select"}
<div class="space-y-2">
<Label for={field.key}>{field.label}</Label>
<Select.Root bind:value={props.searchQuery[field.key]}>
<Select.Trigger id={field.key}>
<Select.Value placeholder={field.placeholder} />
</Select.Trigger>
<Select.Content>
{#each field.options as opt}
<Select.Item value={opt.value}>
{opt.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
{/each}
</div>
<div class="flex justify-end gap-2 mt-4">
<Button variant="outline" size="sm" class="cursor-pointer" onclick={props.onReset}>Reset</Button>
<Button size="sm" class="cursor-pointer" onclick={props.onSearch} disabled={props.isLoading}>
{#if props.isLoading}
<Spinner />
{:else}
Search
{/if}
</Button>
</div>
</div>

View File

@ -0,0 +1,277 @@
<script>
import { Button, buttonVariants } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import * as Popover from "$lib/components/ui/popover/index.js";
import CloudUploadIcon from "@lucide/svelte/icons/cloud-upload";
import FileTextIcon from "@lucide/svelte/icons/file-text";
import XIcon from "@lucide/svelte/icons/x";
import ReusableEmpty from "./reusable-empty.svelte";
let uploadedFiles = $state({ data: [] });
let fileInput = $state();
let files = $state([]);
let { attachments, errors } = $props();
const MAX_SIZE = 2 * 1024 * 1024;
const ALLOWED_EXT = ["jpg", "jpeg", "png", "pdf", "txt", "doc"];
function validateFile(file) {
const ext = file.name.split(".").pop().toLowerCase();
if (!ALLOWED_EXT.includes(ext)) {
return `File type ${ext} is not allowed`;
}
if (file.size > MAX_SIZE) {
return `File size ${file.name} exceeds 2MB`;
}
return null;
}
function handleFiles(selectedFiles) {
const selected = Array.from(selectedFiles)
.map((f) => {
const error = validateFile(f);
if (error) {
errors[f.name] = error;
setTimeout(() => {
delete errors[f.name];
}, 5000);
resetInput();
return null;
}
return {
file: f,
preview: f.type.startsWith("image/") ? URL.createObjectURL(f) : null,
progress: 0,
uploading: false,
uploaded: false,
error: false,
url: null,
xhr: null,
};
})
.filter(Boolean);
files = [...files, ...selected];
}
function selectFiles(e) {
handleFiles(e.target.files);
}
function uploadFile(item) {
item.uploading = true;
item.progress = 0;
item.uploaded = false;
item.error = false;
const xhr = new XMLHttpRequest();
item.xhr = xhr;
xhr.open("POST", "/api/upload");
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
item.progress = Math.round((e.loaded / e.total) * 100);
}
};
xhr.onload = () => {
item.uploading = false;
if (xhr.status === 201) {
try {
const res = JSON.parse(xhr.responseText);
const metadata = res.files.map((f) => ({
Address: `${f.stored_path}`,
}));
attachments = [...attachments, ...metadata];
uploadedFiles.data = attachments;
files = files.filter((f) => f !== item);
item.uploaded = true;
} catch (err) {
console.log(err);
item.error = true;
}
} else {
item.error = true;
}
};
xhr.onerror = () => {
item.uploading = false;
item.error = true;
};
xhr.onabort = () => {
item.uploading = false;
item.progress = 0;
};
const formData = new FormData();
formData.append("files", item.file);
xhr.send(formData);
}
function cancelUpload(item) {
if (item.xhr) item.xhr.abort();
}
function removeFile(index) {
files.splice(index, 1);
files = [...files];
resetInput();
}
async function removeAttachment(index) {
const file = attachments[index];
const res = await fetch("/api/upload", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ Address: file.Address }),
});
const { success, message } = await res.json();
if (success) {
attachments = attachments.filter((_, i) => i !== index);
resetInput();
} else {
showToast(message, "error");
}
}
function resetInput() {
if (fileInput) {
fileInput.value = "";
}
}
</script>
<Popover.Root>
<Popover.Trigger class="!w-full">
<Button variant="ghost" class="!w-full border border-primary border-dashed border-2 cursor-pointer">Manage File</Button>
</Popover.Trigger>
<Popover.Content side="top" class="w-(--bits-popover-anchor-width) border border-dashed border-2 border-primary">
<div class="grid gap-2">
<div class="overflow-y-auto max-h-[450px] p-4 space-y-4">
{#if attachments.length > 0}
<div class="space-y-3">
<h3 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Uploaded</h3>
{#each attachments as item, i}
<div class="flex items-center gap-3 p-3 bg-muted rounded-lg border border-gray-100 hover:border-gray-200 group/item">
<div class="flex-shrink-0 w-10 h-10 flex items-center justify-center bg-white border border-gray-200 rounded-md">
<FileTextIcon class="text-muted-foreground"/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground truncate">
{item.Address.split("/").pop()}
</p>
</div>
<button
type="button"
class="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded-md hover:bg-gray-200 transition-colors opacity-0 group-hover/item:opacity-100"
onclick={() => removeAttachment(i)}
aria-label="Remove file"
>
<XIcon class="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/each}
</div>
{/if}
{#if files.length > 0}
<div class="space-y-3">
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider">To be uploaded</h3>
{#each files as item, i}
<div class="p-3 bg-gray-50 rounded-lg border border-gray-100">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 flex items-center justify-center bg-white border border-gray-200 rounded-md">
<FileTextIcon class="text-muted-foreground"/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">
{item.file.name}
</p>
{#if item.uploading}
<div class="mt-2 space-y-1">
<div class="w-full bg-gray-200 rounded-full h-1.5 overflow-hidden">
<div
class="bg-blue-500 h-full rounded-full transition-all duration-300"
style="width: {item.progress}%"
></div>
</div>
<p class="text-xs text-gray-500">{item.progress}%</p>
</div>
{/if}
</div>
<div class="flex-shrink-0 flex items-center gap-2">
{#if item.uploaded}
<div class="w-6 h-6 flex items-center justify-center rounded-full bg-green-100">
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
{:else if item.uploading}
<button
type="button"
class="px-2.5 py-1 text-xs font-medium text-gray-700 bg-white border border-gray-200 rounded-md hover:bg-gray-50 transition-colors"
onclick={() => cancelUpload(item)}
>
Cancel
</button>
{:else if item.error}
<button
type="button"
class="px-2.5 py-1 text-xs font-medium text-red-700 bg-red-50 border border-red-200 rounded-md hover:bg-red-100 transition-colors"
onclick={() => uploadFile(item)}
>
Retry
</button>
{:else}
<button
type="button"
class="px-2.5 py-1 text-xs font-medium text-blue-700 bg-blue-50 border border-blue-200 rounded-md hover:bg-blue-100 transition-colors"
onclick={() => uploadFile(item)}
>
Upload
</button>
{/if}
{#if !item.uploaded && !item.uploading}
<button
type="button"
class="w-6 h-6 flex items-center justify-center rounded-md hover:bg-gray-200 transition-colors"
onclick={() => removeFile(i)}
aria-label="Remove file"
>
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
{#if attachments.length === 0 && files.length === 0}
<div class="flex flex-col justify-center items-center py-8 text-center">
<ReusableEmpty icon={CloudUploadIcon} desc="No files uploaded yet"/>
</div>
{/if}
</div>
<div class="p-2">
<Label for="uploadfiles" class="flex items-center justify-center w-full px-4 py-2 cursor-pointer bg-primary rounded-md">
<p class="text-primary-foreground">Add Files</p>
<input id="uploadfiles" type="file" class="hidden" multiple bind:this={fileInput} onchange={selectFiles} />
</Label>
</div>
</div>
</Popover.Content>
</Popover.Root>

View File

@ -0,0 +1,51 @@
<script>
import { Button } from "$lib/components/ui/button/index.js";
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import * as Popover from "$lib/components/ui/popover/index.js";
import { mergeProps } from 'bits-ui';
let props = $props();
const { Icon } = props;
</script>
<Tooltip.Provider delayDuration={100}>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger>
{#snippet child({ props: tooltipProps })}
{#if props.popoverContent}
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props: popoverProps })}
<Button
{...mergeProps(tooltipProps, popoverProps)}
variant="ghost"
class={`size-7 ${props.className} cursor-pointer`}
>
<Icon class="w-4 h-4" />
<span class="sr-only">{props.label}</span>
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content ${props.collisionPadding ?? 0} class="w-72">
{@render props.popoverContent()}
</Popover.Content>
</Popover.Root>
{:else}
<Button
{...tooltipProps}
variant="ghost"
class={`size-7 ${props.className} cursor-pointer`}
onclick={props.onClick}
>
<Icon class="w-4 h-4" />
<span class="sr-only">{props.label}</span>
</Button>
{/if}
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{props.label}</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>

View File

@ -0,0 +1,19 @@
<script>
import TopbarAction from "./topbar-action.svelte";
let props = $props();
</script>
<div class="h-8 flex justify-between items-center">
<h3 class="text-2xl font-semibold">
{props.title}
{#if props.subtitle}
<span class="text-muted-foreground">,({props.subtitle})</span>
{/if}
</h3>
<div>
{#each props.actions ?? [] as action}
<TopbarAction {...action} />
{/each}
</div>
</div>

View File

@ -0,0 +1,17 @@
<script>
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
} = $props();
</script>
<AvatarPrimitive.Fallback
bind:ref
data-slot="avatar-fallback"
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
{...restProps}
/>

View File

@ -0,0 +1,17 @@
<script>
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
} = $props();
</script>
<AvatarPrimitive.Image
bind:ref
data-slot="avatar-image"
class={cn("aspect-square size-full", className)}
{...restProps}
/>

View File

@ -0,0 +1,19 @@
<script>
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
loadingStatus = $bindable("loading"),
class: className,
...restProps
} = $props();
</script>
<AvatarPrimitive.Root
bind:ref
bind:loadingStatus
data-slot="avatar"
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
{...restProps}
/>

View File

@ -0,0 +1,13 @@
import Root from "./avatar.svelte";
import Image from "./avatar-image.svelte";
import Fallback from "./avatar-fallback.svelte";
export {
Root,
Image,
Fallback,
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback,
};

View File

@ -0,0 +1,22 @@
<script>
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
} = $props();
</script>
<span
bind:this={ref}
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
class={cn("flex size-9 items-center justify-center", className)}
{...restProps}
>
<EllipsisIcon class="size-4" />
<span class="sr-only">More</span>
</span>

View File

@ -0,0 +1,19 @@
<script>
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
} = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-item"
class={cn("inline-flex items-center gap-1.5", className)}
{...restProps}
>
{@render children?.()}
</li>

View File

@ -0,0 +1,27 @@
<script>
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
href = undefined,
child,
children,
...restProps
} = $props();
const attrs = $derived({
"data-slot": "breadcrumb-link",
class: cn("hover:text-foreground transition-colors", className),
href,
...restProps,
});
</script>
{#if child}
{@render child({ props: attrs })}
{:else}
<a bind:this={ref} {...attrs}>
{@render children?.()}
</a>
{/if}

View File

@ -0,0 +1,22 @@
<script>
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
} = $props();
</script>
<ol
bind:this={ref}
data-slot="breadcrumb-list"
class={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...restProps}
>
{@render children?.()}
</ol>

View File

@ -0,0 +1,22 @@
<script>
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
} = $props();
</script>
<span
bind:this={ref}
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
class={cn("text-foreground font-normal", className)}
{...restProps}
>
{@render children?.()}
</span>

View File

@ -0,0 +1,25 @@
<script>
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
} = $props();
</script>
<li
bind:this={ref}
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
class={cn("[&>svg]:size-3.5", className)}
{...restProps}
>
{#if children}
{@render children?.()}
{:else}
<ChevronRightIcon />
{/if}
</li>

View File

@ -0,0 +1,18 @@
<script>
let {
ref = $bindable(null),
class: className,
children,
...restProps
} = $props();
</script>
<nav
bind:this={ref}
data-slot="breadcrumb"
class={className}
aria-label="breadcrumb"
{...restProps}
>
{@render children?.()}
</nav>

View File

@ -0,0 +1,25 @@
import Root from "./breadcrumb.svelte";
import Ellipsis from "./breadcrumb-ellipsis.svelte";
import Item from "./breadcrumb-item.svelte";
import Separator from "./breadcrumb-separator.svelte";
import Link from "./breadcrumb-link.svelte";
import List from "./breadcrumb-list.svelte";
import Page from "./breadcrumb-page.svelte";
export {
Root,
Ellipsis,
Item,
Separator,
Link,
List,
Page,
//
Root as Breadcrumb,
Ellipsis as BreadcrumbEllipsis,
Item as BreadcrumbItem,
Separator as BreadcrumbSeparator,
Link as BreadcrumbLink,
List as BreadcrumbList,
Page as BreadcrumbPage,
};

View File

@ -0,0 +1,73 @@
<script module>
import { cn } from "$lib/utils.js";
import { tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
destructive:
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
outline:
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
</script>
<script>
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
} = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

@ -0,0 +1,13 @@
import Root, {
buttonVariants,
} from "./button.svelte";
export {
Root,
//
Root as Button,
buttonVariants,
};

View File

@ -0,0 +1,64 @@
<script>
import CalendarMonthSelect from "./calendar-month-select.svelte";
import CalendarYearSelect from "./calendar-year-select.svelte";
import { DateFormatter, getLocalTimeZone } from "@internationalized/date";
let {
captionLayout,
months,
monthFormat,
years,
yearFormat,
month,
locale,
placeholder = $bindable(),
monthIndex = 0,
} = $props();
function formatYear(date) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
}
function formatMonth(date) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
}
</script>
{#snippet MonthSelect()}
<CalendarMonthSelect
{months}
{monthFormat}
value={month.month}
onchange={(e) => {
if (!placeholder) return;
const v = Number.parseInt(e.currentTarget.value);
const newPlaceholder = placeholder.set({ month: v });
placeholder = newPlaceholder.subtract({ months: monthIndex });
}}
/>
{/snippet}
{#snippet YearSelect()}
<CalendarYearSelect {years} {yearFormat} value={month.year} />
{/snippet}
{#if captionLayout === "dropdown"}
{@render MonthSelect()}
{@render YearSelect()}
{:else if captionLayout === "dropdown-months"}
{@render MonthSelect()}
{#if placeholder}
{formatYear(placeholder)}
{/if}
{:else if captionLayout === "dropdown-years"}
{#if placeholder}
{formatMonth(placeholder)}
{/if}
{@render YearSelect()}
{:else}
{formatMonth(month)} {formatYear(month)}
{/if}

View File

@ -0,0 +1,19 @@
<script>
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
} = $props();
</script>
<CalendarPrimitive.Cell
bind:ref
class={cn(
"relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-md [&:last-child[data-selected]_[data-bits-day]]:rounded-e-md",
className
)}
{...restProps}
/>

View File

@ -0,0 +1,35 @@
<script>
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
} = $props();
</script>
<CalendarPrimitive.Day
bind:ref
class={cn(
buttonVariants({ variant: "ghost" }),
"flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none",
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
// Outside months
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
// Disabled
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
// Unavailable
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
// hover
"dark:hover:text-accent-foreground",
// focus
"focus:border-ring focus:ring-ring/50 focus:relative",
// inner spans
"[&>span]:text-xs [&>span]:opacity-70",
className
)}
{...restProps}
/>

View File

@ -0,0 +1,12 @@
<script>
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
} = $props();
</script>
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />

View File

@ -0,0 +1,12 @@
<script>
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
} = $props();
</script>
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />

View File

@ -0,0 +1,12 @@
<script>
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
} = $props();
</script>
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />

View File

@ -0,0 +1,16 @@
<script>
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
} = $props();
</script>
<CalendarPrimitive.Grid
bind:ref
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
{...restProps}
/>

View File

@ -0,0 +1,19 @@
<script>
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
} = $props();
</script>
<CalendarPrimitive.HeadCell
bind:ref
class={cn(
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
className
)}
{...restProps}
/>

View File

@ -0,0 +1,19 @@
<script>
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
} = $props();
</script>
<CalendarPrimitive.Header
bind:ref
class={cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
className
)}
{...restProps}
/>

View File

@ -0,0 +1,16 @@
<script>
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
} = $props();
</script>
<CalendarPrimitive.Heading
bind:ref
class={cn("px-(--cell-size) text-sm font-medium", className)}
{...restProps}
/>

View File

@ -0,0 +1,44 @@
<script>
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
let {
ref = $bindable(null),
class: className,
value,
onchange,
...restProps
} = $props();
</script>
<span
class={cn(
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
className
)}
>
<CalendarPrimitive.MonthSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
{#snippet child({ props, monthItems, selectedMonthItem })}
<select {...props} {value} {onchange}>
{#each monthItems as monthItem (monthItem.value)}
<option
value={monthItem.value}
selected={value !== undefined
? monthItem.value === value
: monthItem.value === selectedMonthItem.value}
>
{monthItem.label}
</option>
{/each}
</select>
<span
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
aria-hidden="true"
>
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
<ChevronDownIcon class="size-4" />
</span>
{/snippet}
</CalendarPrimitive.MonthSelect>
</span>

View File

@ -0,0 +1,13 @@
<script>
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
} = $props();
</script>
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
{@render children?.()}
</div>

View File

@ -0,0 +1,18 @@
<script>
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
} = $props();
</script>
<div
bind:this={ref}
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@ -0,0 +1,17 @@
<script>
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
} = $props();
</script>
<nav
{...restProps}
bind:this={ref}
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
>
{@render children?.()}
</nav>

View File

@ -0,0 +1,29 @@
<script>
import { Calendar as CalendarPrimitive } from "bits-ui";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
variant = "ghost",
...restProps
} = $props();
</script>
{#snippet Fallback()}
<ChevronRightIcon class="size-4" />
{/snippet}
<CalendarPrimitive.NextButton
bind:ref
class={cn(
buttonVariants({ variant }),
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
className
)}
children={children || Fallback}
{...restProps}
/>

View File

@ -0,0 +1,29 @@
<script>
import { Calendar as CalendarPrimitive } from "bits-ui";
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
variant = "ghost",
...restProps
} = $props();
</script>
{#snippet Fallback()}
<ChevronLeftIcon class="size-4" />
{/snippet}
<CalendarPrimitive.PrevButton
bind:ref
class={cn(
buttonVariants({ variant }),
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
className
)}
children={children || Fallback}
{...restProps}
/>

View File

@ -0,0 +1,43 @@
<script>
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
let {
ref = $bindable(null),
class: className,
value,
...restProps
} = $props();
</script>
<span
class={cn(
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
className
)}
>
<CalendarPrimitive.YearSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
{#snippet child({ props, yearItems, selectedYearItem })}
<select {...props} {value}>
{#each yearItems as yearItem (yearItem.value)}
<option
value={yearItem.value}
selected={value !== undefined
? yearItem.value === value
: yearItem.value === selectedYearItem.value}
>
{yearItem.label}
</option>
{/each}
</select>
<span
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
aria-hidden="true"
>
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
<ChevronDownIcon class="size-4" />
</span>
{/snippet}
</CalendarPrimitive.YearSelect>
</span>

View File

@ -0,0 +1,104 @@
<script>
import { Calendar as CalendarPrimitive } from "bits-ui";
import * as Calendar from "./index.js";
import { cn } from "$lib/utils.js";
import { isEqualMonth } from "@internationalized/date";
let {
ref = $bindable(null),
value = $bindable(),
placeholder = $bindable(),
class: className,
weekdayFormat = "short",
buttonVariant = "ghost",
captionLayout = "label",
locale = "en-US",
months: monthsProp,
years,
monthFormat: monthFormatProp,
yearFormat = "numeric",
day,
disableDaysOutsideMonth = false,
...restProps
} = $props();
const monthFormat = $derived.by(() => {
if (monthFormatProp) return monthFormatProp;
if (captionLayout.startsWith("dropdown")) return "short";
return "long";
});
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<CalendarPrimitive.Root
bind:value={value}
bind:ref
bind:placeholder
{weekdayFormat}
{disableDaysOutsideMonth}
class={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
className
)}
{locale}
{monthFormat}
{yearFormat}
{...restProps}
>
{#snippet children({ months, weekdays })}
<Calendar.Months>
<Calendar.Nav>
<Calendar.PrevButton variant={buttonVariant} />
<Calendar.NextButton variant={buttonVariant} />
</Calendar.Nav>
{#each months as month, monthIndex (month)}
<Calendar.Month>
<Calendar.Header>
<Calendar.Caption
{captionLayout}
months={monthsProp}
{monthFormat}
{years}
{yearFormat}
month={month.value}
bind:placeholder
{locale}
{monthIndex}
/>
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHead>
<Calendar.GridRow class="select-none">
{#each weekdays as weekday (weekday)}
<Calendar.HeadCell>
{weekday.slice(0, 2)}
</Calendar.HeadCell>
{/each}
</Calendar.GridRow>
</Calendar.GridHead>
<Calendar.GridBody>
{#each month.weeks as weekDates (weekDates)}
<Calendar.GridRow class="mt-2 w-full">
{#each weekDates as date (date)}
<Calendar.Cell {date} month={month.value}>
{#if day}
{@render day({
day: date,
outsideMonth: !isEqualMonth(date, month.value),
})}
{:else}
<Calendar.Day />
{/if}
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
</Calendar.Month>
{/each}
</Calendar.Months>
{/snippet}
</CalendarPrimitive.Root>

View File

@ -0,0 +1,40 @@
import Root from "./calendar.svelte";
import Cell from "./calendar-cell.svelte";
import Day from "./calendar-day.svelte";
import Grid from "./calendar-grid.svelte";
import Header from "./calendar-header.svelte";
import Months from "./calendar-months.svelte";
import GridRow from "./calendar-grid-row.svelte";
import Heading from "./calendar-heading.svelte";
import GridBody from "./calendar-grid-body.svelte";
import GridHead from "./calendar-grid-head.svelte";
import HeadCell from "./calendar-head-cell.svelte";
import NextButton from "./calendar-next-button.svelte";
import PrevButton from "./calendar-prev-button.svelte";
import MonthSelect from "./calendar-month-select.svelte";
import YearSelect from "./calendar-year-select.svelte";
import Month from "./calendar-month.svelte";
import Nav from "./calendar-nav.svelte";
import Caption from "./calendar-caption.svelte";
export {
Day,
Cell,
Grid,
Header,
Months,
GridRow,
Heading,
GridBody,
GridHead,
HeadCell,
NextButton,
PrevButton,
Nav,
Month,
YearSelect,
MonthSelect,
Caption,
//
Root as Calendar,
};

View File

@ -0,0 +1,18 @@
<script>
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
} = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@ -0,0 +1,14 @@
<script>
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
} = $props();
</script>
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
{@render children?.()}
</div>

View File

@ -0,0 +1,19 @@
<script>
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
} = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>

View File

@ -0,0 +1,18 @@
<script>
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
} = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@ -0,0 +1,21 @@
<script>
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
} = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@ -0,0 +1,19 @@
<script>
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
} = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("leading-none font-semibold", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@ -0,0 +1,22 @@
<script>
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
} = $props();
</script>
<div
bind:this={ref}
data-slot="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};

View File

@ -0,0 +1,36 @@
<script>
import { Checkbox as CheckboxPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import MinusIcon from "@lucide/svelte/icons/minus";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
} = $props();
</script>
<CheckboxPrimitive.Root
bind:ref
data-slot="checkbox"
class={cn(
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:checked
bind:indeterminate
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<div data-slot="checkbox-indicator" class="text-current transition-none">
{#if checked}
<CheckIcon class="size-3.5" />
{:else if indeterminate}
<MinusIcon class="size-3.5" />
{/if}
</div>
{/snippet}
</CheckboxPrimitive.Root>

View File

@ -0,0 +1,6 @@
import Root from "./checkbox.svelte";
export {
Root,
//
Root as Checkbox,
};

View File

@ -0,0 +1,7 @@
<script>
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps } = $props();
</script>
<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />

View File

@ -0,0 +1,7 @@
<script>
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps } = $props();
</script>
<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />

View File

@ -0,0 +1,11 @@
<script>
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let {
ref = $bindable(null),
open = $bindable(false),
...restProps
} = $props();
</script>
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />

View File

@ -0,0 +1,13 @@
import Root from "./collapsible.svelte";
import Trigger from "./collapsible-trigger.svelte";
import Content from "./collapsible-content.svelte";
export {
Root,
Content,
Trigger,
//
Root as Collapsible,
Content as CollapsibleContent,
Trigger as CollapsibleTrigger,
};

View File

@ -0,0 +1,134 @@
import {
createTable,
} from "@tanstack/table-core";
/**
* Creates a reactive TanStack table object for Svelte.
* @param options Table options to create the table with.
* @returns A reactive table object.
* @example
* ```svelte
* <script>
* const table = createSvelteTable({ ... })
* </script>
*
* <table>
* <thead>
* {#each table.getHeaderGroups() as headerGroup}
* <tr>
* {#each headerGroup.headers as header}
* <th colspan={header.colSpan}>
* <FlexRender content={header.column.columnDef.header} context={header.getContext()} />
* </th>
* {/each}
* </tr>
* {/each}
* </thead>
* <!-- ... -->
* </table>
* ```
*/
export function createSvelteTable(options) {
const resolvedOptions = mergeObjects(
{
state: {},
onStateChange() {},
renderFallbackValue: null,
mergeOptions: (
defaultOptions,
options
) => {
return mergeObjects(defaultOptions, options);
},
},
options
);
const table = createTable(resolvedOptions);
let state = $state(table.initialState);
function updateOptions() {
table.setOptions((prev) => {
return mergeObjects(prev, options, {
state: mergeObjects(state, options.state || {}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onStateChange: (updater) => {
if (updater instanceof Function) state = updater(state);
else state = mergeObjects(state, updater);
options.onStateChange?.(updater);
},
});
});
}
updateOptions();
$effect.pre(() => {
updateOptions();
});
return table;
}
/**
* Lazily merges several objects (or thunks) while preserving
* getter semantics from every source.
*
* Proxy-based to avoid known WebKit recursion issue.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function mergeObjects(
...sources
) {
const resolve = (src) =>
typeof src === "function" ? (src() ?? undefined) : src;
const findSourceWithKey = (key) => {
for (let i = sources.length - 1; i >= 0; i--) {
const obj = resolve(sources[i]);
if (obj && key in obj) return obj;
}
return undefined;
};
return new Proxy(Object.create(null), {
get(_, key) {
const src = findSourceWithKey(key);
return src?.[key ];
},
has(_, key) {
return !!findSourceWithKey(key);
},
ownKeys() {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const all = new Set();
for (const s of sources) {
const obj = resolve(s);
if (obj) {
for (const k of Reflect.ownKeys(obj) ) {
all.add(k);
}
}
}
return [...all];
},
getOwnPropertyDescriptor(_, key) {
const src = findSourceWithKey(key);
if (!src) return undefined;
return {
configurable: true,
enumerable: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: (src )[key],
writable: true,
};
},
}) ;
}

View File

@ -0,0 +1,24 @@
<script
lang="ts"
generics="TData, TValue, TContext extends HeaderContext<TData, TValue> | CellContext<TData, TValue>"
>
import { RenderComponentConfig, RenderSnippetConfig } from "./render-helpers.js";
let { content, context, attach } = $props();
</script>
{#if typeof content === "string"}
{content}
{:else if content instanceof Function}
<!-- It's unlikely that a CellContext will be passed to a Header -->
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
{@const result = content(context)}
{#if result instanceof RenderComponentConfig}
{@const { component: Component, props } = result}
<Component {...props} {attach} />
{:else if result instanceof RenderSnippetConfig}
{@const { snippet, params } = result}
{@render snippet({ ...params, attach })}
{:else}
{result}
{/if}
{/if}

View File

@ -0,0 +1,3 @@
export { default as FlexRender } from "./flex-render.svelte";
export { renderComponent, renderSnippet } from "./render-helpers.js";
export { createSvelteTable } from "./data-table.svelte.js";

View File

@ -0,0 +1,107 @@
/**
* A helper class to make it easy to identify Svelte components in
* `columnDef.cell` and `columnDef.header` properties.
*
* > NOTE: This class should only be used internally by the adapter. If you're
* reading this and you don't know what this is for, you probably don't need it.
*
* @example
* ```svelte
* {@const result = content(context as any)}
* {#if result instanceof RenderComponentConfig}
* {@const { component: Component, props } = result}
* <Component {...props} />
* {/if}
* ```
*/
export class RenderComponentConfig {
component;
props;
constructor(
component,
props = {}
) {
this.component = component;
this.props = props;
}
}
/**
* A helper class to make it easy to identify Svelte Snippets in `columnDef.cell` and `columnDef.header` properties.
*
* > NOTE: This class should only be used internally by the adapter. If you're
* reading this and you don't know what this is for, you probably don't need it.
*
* @example
* ```svelte
* {@const result = content(context as any)}
* {#if result instanceof RenderSnippetConfig}
* {@const { snippet, params } = result}
* {@render snippet(params)}
* {/if}
* ```
*/
export class RenderSnippetConfig {
snippet;
params;
constructor(snippet, params) {
this.snippet = snippet;
this.params = params;
}
}
/**
* A helper function to help create cells from Svelte components through ColumnDef's `cell` and `header` properties.
*
* This is only to be used with Svelte Components - use `renderSnippet` for Svelte Snippets.
*
* @param component A Svelte component
* @param props The props to pass to `component`
* @returns A `RenderComponentConfig` object that helps svelte-table know how to render the header/cell component.
* @example
* ```ts
* // +page.svelte
* const defaultColumns = [
* columnHelper.accessor('name', {
* header: header => renderComponent(SortHeader, { label: 'Name', header }),
* }),
* columnHelper.accessor('state', {
* header: header => renderComponent(SortHeader, { label: 'State', header }),
* }),
* ]
* ```
* @see {@link https://tanstack.com/table/latest/docs/guide/column-defs}
*/
export function renderComponent
(component, props = {} ) {
return new RenderComponentConfig(component, props);
}
/**
* A helper function to help create cells from Svelte Snippets through ColumnDef's `cell` and `header` properties.
*
* The snippet must only take one parameter.
*
* This is only to be used with Snippets - use `renderComponent` for Svelte Components.
*
* @param snippet
* @param params
* @returns - A `RenderSnippetConfig` object that helps svelte-table know how to render the header/cell snippet.
* @example
* ```ts
* // +page.svelte
* const defaultColumns = [
* columnHelper.accessor('name', {
* cell: cell => renderSnippet(nameSnippet, { name: cell.row.name }),
* }),
* columnHelper.accessor('state', {
* cell: cell => renderSnippet(stateSnippet, { state: cell.row.state }),
* }),
* ]
* ```
* @see {@link https://tanstack.com/table/latest/docs/guide/column-defs}
*/
export function renderSnippet(snippet, params = {} ) {
return new RenderSnippetConfig(snippet, params);
}

View File

@ -0,0 +1,7 @@
<script>
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps } = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />

View File

@ -0,0 +1,38 @@
<script>
import { Dialog as DialogPrimitive } from "bits-ui";
import DialogPortal from "./dialog-portal.svelte";
import XIcon from "@lucide/svelte/icons/x";
import * as Dialog from "./index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
} = $props();
</script>
<DialogPortal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</DialogPortal>

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