mirror of
https://github.com/faiztyanirh/clqms-shadcn-v1.git
synced 2026-04-25 10:32:06 +07:00
initial commit
This commit is contained in:
commit
0c1e54fc3d
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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-*
|
||||
9
.prettierignore
Normal file
9
.prettierignore
Normal 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
15
.prettierrc
Normal 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
38
README.md
Normal 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
16
components.json
Normal 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
26
eslint.config.js
Normal 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
13
jsconfig.json
Normal 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
3742
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
package.json
Normal file
45
package.json
Normal 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
121
src/app.css
Normal 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
11
src/app.html
Normal 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
110
src/lib/api/api-client.js
Normal 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' };
|
||||
}
|
||||
}
|
||||
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal 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 |
166
src/lib/components/app-sidebar.svelte
Normal file
166
src/lib/components/app-sidebar.svelte
Normal 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>
|
||||
99
src/lib/components/composable/use-form-option.svelte.js
Normal file
99
src/lib/components/composable/use-form-option.svelte.js
Normal 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,
|
||||
};
|
||||
}
|
||||
15
src/lib/components/composable/use-form-state.svelte.js
Normal file
15
src/lib/components/composable/use-form-state.svelte.js
Normal 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 }
|
||||
}
|
||||
31
src/lib/components/composable/use-form-validation.svelte.js
Normal file
31
src/lib/components/composable/use-form-validation.svelte.js
Normal 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 }
|
||||
}
|
||||
37
src/lib/components/composable/use-form.svelte.js
Normal file
37
src/lib/components/composable/use-form.svelte.js
Normal 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,
|
||||
}
|
||||
}
|
||||
102
src/lib/components/composable/use-patient-form.svelte.js
Normal file
102
src/lib/components/composable/use-patient-form.svelte.js
Normal 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 },
|
||||
}
|
||||
}
|
||||
136
src/lib/components/composable/useMasterDetail.svelte.js
Normal file
136
src/lib/components/composable/useMasterDetail.svelte.js
Normal 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,
|
||||
};
|
||||
}
|
||||
20
src/lib/components/composable/useResponsive.svelte.js
Normal file
20
src/lib/components/composable/useResponsive.svelte.js
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
41
src/lib/components/composable/useSearch.svelte.js
Normal file
41
src/lib/components/composable/useSearch.svelte.js
Normal 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
|
||||
};
|
||||
}
|
||||
61
src/lib/components/nav-dictionary.svelte
Normal file
61
src/lib/components/nav-dictionary.svelte
Normal 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>
|
||||
115
src/lib/components/nav-main.svelte
Normal file
115
src/lib/components/nav-main.svelte
Normal 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>
|
||||
87
src/lib/components/nav-user.svelte
Normal file
87
src/lib/components/nav-user.svelte
Normal 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>
|
||||
21
src/lib/components/patient/api/patient-api.js
Normal file
21
src/lib/components/patient/api/patient-api.js
Normal 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)
|
||||
}
|
||||
145
src/lib/components/patient/config/patient-config.js
Normal file
145
src/lib/components/patient/config/patient-config.js
Normal 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,
|
||||
|
||||
},
|
||||
]
|
||||
}
|
||||
311
src/lib/components/patient/config/patient-form-config.js
Normal file
311
src/lib/components/patient/config/patient-form-config.js
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
157
src/lib/components/patient/modal/custodian-modal.svelte
Normal file
157
src/lib/components/patient/modal/custodian-modal.svelte
Normal 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>
|
||||
286
src/lib/components/patient/modal/linkto-modal.svelte
Normal file
286
src/lib/components/patient/modal/linkto-modal.svelte
Normal 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> -->
|
||||
403
src/lib/components/patient/page/create-page copy.svelte
Normal file
403
src/lib/components/patient/page/create-page copy.svelte
Normal 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>
|
||||
74
src/lib/components/patient/page/create-page.svelte
Normal file
74
src/lib/components/patient/page/create-page.svelte
Normal 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>
|
||||
523
src/lib/components/patient/page/edit-page.svelte
Normal file
523
src/lib/components/patient/page/edit-page.svelte
Normal 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> -->
|
||||
63
src/lib/components/patient/page/master-page.svelte
Normal file
63
src/lib/components/patient/page/master-page.svelte
Normal 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>
|
||||
123
src/lib/components/patient/page/view-page.svelte
Normal file
123
src/lib/components/patient/page/view-page.svelte
Normal 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}
|
||||
@ -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>
|
||||
334
src/lib/components/patient/reusable/patient-form-renderer.svelte
Normal file
334
src/lib/components/patient/reusable/patient-form-renderer.svelte
Normal 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>
|
||||
23
src/lib/components/patient/table/colums.js
Normal file
23
src/lib/components/patient/table/colums.js
Normal 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];
|
||||
}
|
||||
},
|
||||
];
|
||||
196
src/lib/components/reusable/reusable-calendar-timepicker.svelte
Normal file
196
src/lib/components/reusable/reusable-calendar-timepicker.svelte
Normal 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> -->
|
||||
71
src/lib/components/reusable/reusable-calendar.svelte
Normal file
71
src/lib/components/reusable/reusable-calendar.svelte
Normal 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>
|
||||
187
src/lib/components/reusable/reusable-data-table.svelte
Normal file
187
src/lib/components/reusable/reusable-data-table.svelte
Normal 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>
|
||||
19
src/lib/components/reusable/reusable-empty.svelte
Normal file
19
src/lib/components/reusable/reusable-empty.svelte
Normal 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>
|
||||
53
src/lib/components/reusable/reusable-search-param.svelte
Normal file
53
src/lib/components/reusable/reusable-search-param.svelte
Normal 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>
|
||||
277
src/lib/components/reusable/reusable-upload.svelte
Normal file
277
src/lib/components/reusable/reusable-upload.svelte
Normal 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>
|
||||
51
src/lib/components/topbar/topbar-action.svelte
Normal file
51
src/lib/components/topbar/topbar-action.svelte
Normal 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>
|
||||
19
src/lib/components/topbar/topbar-wrapper.svelte
Normal file
19
src/lib/components/topbar/topbar-wrapper.svelte
Normal 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>
|
||||
17
src/lib/components/ui/avatar/avatar-fallback.svelte
Normal file
17
src/lib/components/ui/avatar/avatar-fallback.svelte
Normal 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}
|
||||
/>
|
||||
17
src/lib/components/ui/avatar/avatar-image.svelte
Normal file
17
src/lib/components/ui/avatar/avatar-image.svelte
Normal 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}
|
||||
/>
|
||||
19
src/lib/components/ui/avatar/avatar.svelte
Normal file
19
src/lib/components/ui/avatar/avatar.svelte
Normal 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}
|
||||
/>
|
||||
13
src/lib/components/ui/avatar/index.js
Normal file
13
src/lib/components/ui/avatar/index.js
Normal 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,
|
||||
};
|
||||
22
src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte
Normal file
22
src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte
Normal 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>
|
||||
19
src/lib/components/ui/breadcrumb/breadcrumb-item.svelte
Normal file
19
src/lib/components/ui/breadcrumb/breadcrumb-item.svelte
Normal 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>
|
||||
27
src/lib/components/ui/breadcrumb/breadcrumb-link.svelte
Normal file
27
src/lib/components/ui/breadcrumb/breadcrumb-link.svelte
Normal 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}
|
||||
22
src/lib/components/ui/breadcrumb/breadcrumb-list.svelte
Normal file
22
src/lib/components/ui/breadcrumb/breadcrumb-list.svelte
Normal 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>
|
||||
22
src/lib/components/ui/breadcrumb/breadcrumb-page.svelte
Normal file
22
src/lib/components/ui/breadcrumb/breadcrumb-page.svelte
Normal 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>
|
||||
25
src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte
Normal file
25
src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte
Normal 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>
|
||||
18
src/lib/components/ui/breadcrumb/breadcrumb.svelte
Normal file
18
src/lib/components/ui/breadcrumb/breadcrumb.svelte
Normal 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>
|
||||
25
src/lib/components/ui/breadcrumb/index.js
Normal file
25
src/lib/components/ui/breadcrumb/index.js
Normal 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,
|
||||
};
|
||||
73
src/lib/components/ui/button/button.svelte
Normal file
73
src/lib/components/ui/button/button.svelte
Normal 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}
|
||||
13
src/lib/components/ui/button/index.js
Normal file
13
src/lib/components/ui/button/index.js
Normal file
@ -0,0 +1,13 @@
|
||||
import Root, {
|
||||
|
||||
buttonVariants,
|
||||
} from "./button.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
|
||||
};
|
||||
64
src/lib/components/ui/calendar/calendar-caption.svelte
Normal file
64
src/lib/components/ui/calendar/calendar-caption.svelte
Normal 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}
|
||||
19
src/lib/components/ui/calendar/calendar-cell.svelte
Normal file
19
src/lib/components/ui/calendar/calendar-cell.svelte
Normal 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}
|
||||
/>
|
||||
35
src/lib/components/ui/calendar/calendar-day.svelte
Normal file
35
src/lib/components/ui/calendar/calendar-day.svelte
Normal 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}
|
||||
/>
|
||||
12
src/lib/components/ui/calendar/calendar-grid-body.svelte
Normal file
12
src/lib/components/ui/calendar/calendar-grid-body.svelte
Normal 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} />
|
||||
12
src/lib/components/ui/calendar/calendar-grid-head.svelte
Normal file
12
src/lib/components/ui/calendar/calendar-grid-head.svelte
Normal 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} />
|
||||
12
src/lib/components/ui/calendar/calendar-grid-row.svelte
Normal file
12
src/lib/components/ui/calendar/calendar-grid-row.svelte
Normal 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} />
|
||||
16
src/lib/components/ui/calendar/calendar-grid.svelte
Normal file
16
src/lib/components/ui/calendar/calendar-grid.svelte
Normal 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}
|
||||
/>
|
||||
19
src/lib/components/ui/calendar/calendar-head-cell.svelte
Normal file
19
src/lib/components/ui/calendar/calendar-head-cell.svelte
Normal 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}
|
||||
/>
|
||||
19
src/lib/components/ui/calendar/calendar-header.svelte
Normal file
19
src/lib/components/ui/calendar/calendar-header.svelte
Normal 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}
|
||||
/>
|
||||
16
src/lib/components/ui/calendar/calendar-heading.svelte
Normal file
16
src/lib/components/ui/calendar/calendar-heading.svelte
Normal 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}
|
||||
/>
|
||||
44
src/lib/components/ui/calendar/calendar-month-select.svelte
Normal file
44
src/lib/components/ui/calendar/calendar-month-select.svelte
Normal 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>
|
||||
13
src/lib/components/ui/calendar/calendar-month.svelte
Normal file
13
src/lib/components/ui/calendar/calendar-month.svelte
Normal 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>
|
||||
18
src/lib/components/ui/calendar/calendar-months.svelte
Normal file
18
src/lib/components/ui/calendar/calendar-months.svelte
Normal 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>
|
||||
17
src/lib/components/ui/calendar/calendar-nav.svelte
Normal file
17
src/lib/components/ui/calendar/calendar-nav.svelte
Normal 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>
|
||||
29
src/lib/components/ui/calendar/calendar-next-button.svelte
Normal file
29
src/lib/components/ui/calendar/calendar-next-button.svelte
Normal 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}
|
||||
/>
|
||||
29
src/lib/components/ui/calendar/calendar-prev-button.svelte
Normal file
29
src/lib/components/ui/calendar/calendar-prev-button.svelte
Normal 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}
|
||||
/>
|
||||
43
src/lib/components/ui/calendar/calendar-year-select.svelte
Normal file
43
src/lib/components/ui/calendar/calendar-year-select.svelte
Normal 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>
|
||||
104
src/lib/components/ui/calendar/calendar.svelte
Normal file
104
src/lib/components/ui/calendar/calendar.svelte
Normal 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>
|
||||
40
src/lib/components/ui/calendar/index.js
Normal file
40
src/lib/components/ui/calendar/index.js
Normal 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,
|
||||
};
|
||||
18
src/lib/components/ui/card/card-action.svelte
Normal file
18
src/lib/components/ui/card/card-action.svelte
Normal 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>
|
||||
14
src/lib/components/ui/card/card-content.svelte
Normal file
14
src/lib/components/ui/card/card-content.svelte
Normal 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>
|
||||
19
src/lib/components/ui/card/card-description.svelte
Normal file
19
src/lib/components/ui/card/card-description.svelte
Normal 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>
|
||||
18
src/lib/components/ui/card/card-footer.svelte
Normal file
18
src/lib/components/ui/card/card-footer.svelte
Normal 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>
|
||||
21
src/lib/components/ui/card/card-header.svelte
Normal file
21
src/lib/components/ui/card/card-header.svelte
Normal 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>
|
||||
19
src/lib/components/ui/card/card-title.svelte
Normal file
19
src/lib/components/ui/card/card-title.svelte
Normal 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>
|
||||
22
src/lib/components/ui/card/card.svelte
Normal file
22
src/lib/components/ui/card/card.svelte
Normal 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>
|
||||
25
src/lib/components/ui/card/index.js
Normal file
25
src/lib/components/ui/card/index.js
Normal 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,
|
||||
};
|
||||
36
src/lib/components/ui/checkbox/checkbox.svelte
Normal file
36
src/lib/components/ui/checkbox/checkbox.svelte
Normal 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>
|
||||
6
src/lib/components/ui/checkbox/index.js
Normal file
6
src/lib/components/ui/checkbox/index.js
Normal file
@ -0,0 +1,6 @@
|
||||
import Root from "./checkbox.svelte";
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Checkbox,
|
||||
};
|
||||
@ -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} />
|
||||
@ -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} />
|
||||
11
src/lib/components/ui/collapsible/collapsible.svelte
Normal file
11
src/lib/components/ui/collapsible/collapsible.svelte
Normal 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} />
|
||||
13
src/lib/components/ui/collapsible/index.js
Normal file
13
src/lib/components/ui/collapsible/index.js
Normal 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,
|
||||
};
|
||||
134
src/lib/components/ui/data-table/data-table.svelte.js
Normal file
134
src/lib/components/ui/data-table/data-table.svelte.js
Normal 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,
|
||||
};
|
||||
},
|
||||
}) ;
|
||||
}
|
||||
24
src/lib/components/ui/data-table/flex-render.svelte
Normal file
24
src/lib/components/ui/data-table/flex-render.svelte
Normal 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}
|
||||
3
src/lib/components/ui/data-table/index.js
Normal file
3
src/lib/components/ui/data-table/index.js
Normal 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";
|
||||
107
src/lib/components/ui/data-table/render-helpers.js
Normal file
107
src/lib/components/ui/data-table/render-helpers.js
Normal 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);
|
||||
}
|
||||
7
src/lib/components/ui/dialog/dialog-close.svelte
Normal file
7
src/lib/components/ui/dialog/dialog-close.svelte
Normal 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} />
|
||||
38
src/lib/components/ui/dialog/dialog-content.svelte
Normal file
38
src/lib/components/ui/dialog/dialog-content.svelte
Normal 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
Loading…
x
Reference in New Issue
Block a user