This commit is contained in:
Chris Chen
2026-05-28 22:29:13 -07:00
parent a2d394029a
commit 0639d1fe83
23 changed files with 896 additions and 158 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}
+4 -2
View File
@@ -48,7 +48,8 @@
"src/assets" "src/assets"
], ],
"styles": [ "styles": [
"src/styles.scss" "src/styles.scss",
"src/tailwind.css"
] ]
}, },
"configurations": { "configurations": {
@@ -106,7 +107,8 @@
"src/assets" "src/assets"
], ],
"styles": [ "styles": [
"src/styles.scss" "src/styles.scss",
"src/tailwind.css"
] ]
} }
} }
+2 -2
View File
@@ -4,7 +4,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Escrow Portal Access - ROLCC AC</title> <title>Church Portal Access - ROLCC AC</title>
<style> <style>
/* Email-safe CSS - compatible with most email clients */ /* Email-safe CSS - compatible with most email clients */
body { body {
@@ -233,7 +233,7 @@
<tr> <tr>
<td class="email-header" <td class="email-header"
style="background-color: #1e3a8a; padding: 30px 20px; text-align: center; color: white;"> style="background-color: #1e3a8a; padding: 30px 20px; text-align: center; color: white;">
<img src="assets/rbj-logo.svg" alt="RBJ Logo" class="logo-image" <img src="assets/images/ROLCC-Logo-Color.png" alt="ROLCC Logo" class="logo-image"
style="height: 50px; width: auto; margin-bottom: 15px;"> style="height: 50px; width: auto; margin-bottom: 15px;">
<div class="logo-text"> <div class="logo-text">
<h1 style="font-size: 28px; font-weight: bold; margin: 0 0 5px 0;">ROLCC AC</h1> <h1 style="font-size: 28px; font-weight: bold; margin: 0 0 5px 0;">ROLCC AC</h1>
+621 -8
View File
@@ -63,6 +63,7 @@
"@angular/cli": "^20.1.6", "@angular/cli": "^20.1.6",
"@angular/compiler-cli": "^20.1.0", "@angular/compiler-cli": "^20.1.0",
"@angular/localize": "^20.2.1", "@angular/localize": "^20.2.1",
"@tailwindcss/postcss": "^4.3.0",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"jasmine-core": "~5.8.0", "jasmine-core": "~5.8.0",
"karma": "~6.4.0", "karma": "~6.4.0",
@@ -70,6 +71,8 @@
"karma-coverage": "~2.2.0", "karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"postcss": "^8.5.15",
"tailwindcss": "^4.3.0",
"typescript": "~5.8.2" "typescript": "~5.8.2"
} }
}, },
@@ -282,6 +285,19 @@
"node": ">= 14.0.0" "node": ">= 14.0.0"
} }
}, },
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@ampproject/remapping": { "node_modules/@ampproject/remapping": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
@@ -1910,6 +1926,17 @@
"@jridgewell/trace-mapping": "^0.3.24" "@jridgewell/trace-mapping": "^0.3.24"
} }
}, },
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": { "node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -4830,6 +4857,287 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tailwindcss/node": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
"integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.21.0",
"jiti": "^2.6.1",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.3.0"
}
},
"node_modules/@tailwindcss/node/node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz",
"integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.3.0",
"@tailwindcss/oxide-darwin-arm64": "4.3.0",
"@tailwindcss/oxide-darwin-x64": "4.3.0",
"@tailwindcss/oxide-freebsd-x64": "4.3.0",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.0",
"@tailwindcss/oxide-linux-arm64-musl": "4.3.0",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.0",
"@tailwindcss/oxide-linux-x64-musl": "4.3.0",
"@tailwindcss/oxide-wasm32-wasi": "4.3.0",
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.0",
"@tailwindcss/oxide-win32-x64-msvc": "4.3.0"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz",
"integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz",
"integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz",
"integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz",
"integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz",
"integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz",
"integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz",
"integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz",
"integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz",
"integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz",
"integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.10.0",
"@emnapi/runtime": "^1.10.0",
"@emnapi/wasi-threads": "^1.2.1",
"@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
"integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz",
"integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/postcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz",
"integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.3.0",
"@tailwindcss/oxide": "4.3.0",
"postcss": "^8.5.10",
"tailwindcss": "4.3.0"
}
},
"node_modules/@tufjs/canonical-json": { "node_modules/@tufjs/canonical-json": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz",
@@ -5930,7 +6238,6 @@
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -6178,6 +6485,20 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/enhanced-resolve": {
"version": "5.22.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz",
"integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/ent": { "node_modules/ent": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz",
@@ -7337,6 +7658,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -7924,6 +8255,267 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/listr2": { "node_modules/listr2": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.1.tgz", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.1.tgz",
@@ -8597,9 +9189,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -9376,9 +9968,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -9396,7 +9988,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -10576,6 +11168,27 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tar": { "node_modules/tar": {
"version": "6.2.1", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
+3
View File
@@ -77,6 +77,7 @@
"@angular/cli": "^20.1.6", "@angular/cli": "^20.1.6",
"@angular/compiler-cli": "^20.1.0", "@angular/compiler-cli": "^20.1.0",
"@angular/localize": "^20.2.1", "@angular/localize": "^20.2.1",
"@tailwindcss/postcss": "^4.3.0",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"jasmine-core": "~5.8.0", "jasmine-core": "~5.8.0",
"karma": "~6.4.0", "karma": "~6.4.0",
@@ -84,6 +85,8 @@
"karma-coverage": "~2.2.0", "karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"postcss": "^8.5.15",
"tailwindcss": "^4.3.0",
"typescript": "~5.8.2" "typescript": "~5.8.2"
} }
} }
@@ -1,10 +1,10 @@
<kendo-dialog title="Quick add member" (close)="cancelled.emit()" [width]="420"> <kendo-dialog title="Quick add member" (close)="cancelled.emit()" [width]="420">
<div style="display:flex;flex-direction:column;gap:0.75rem;"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label>First name (EN) *<kendo-textbox [(ngModel)]="firstName_en"></kendo-textbox></label> <label class="flex flex-col gap-1">First name (EN) *<kendo-textbox [(ngModel)]="firstName_en"></kendo-textbox></label>
<label>Last name (EN) *<kendo-textbox [(ngModel)]="lastName_en"></kendo-textbox></label> <label class="flex flex-col gap-1">Last name (EN) *<kendo-textbox [(ngModel)]="lastName_en"></kendo-textbox></label>
<label>名 (中)<kendo-textbox [(ngModel)]="firstName_zh"></kendo-textbox></label> <label class="flex flex-col gap-1">名 (中)<kendo-textbox [(ngModel)]="firstName_zh"></kendo-textbox></label>
<label>姓 (中)<kendo-textbox [(ngModel)]="lastName_zh"></kendo-textbox></label> <label class="flex flex-col gap-1">姓 (中)<kendo-textbox [(ngModel)]="lastName_zh"></kendo-textbox></label>
<label>Cell phone<kendo-textbox [(ngModel)]="phoneCell"></kendo-textbox></label> <label class="flex flex-col gap-1 md:col-span-2">Cell phone<kendo-textbox [(ngModel)]="phoneCell"></kendo-textbox></label>
</div> </div>
<kendo-dialog-actions> <kendo-dialog-actions>
<button kendoButton (click)="cancelled.emit()">Cancel</button> <button kendoButton (click)="cancelled.emit()">Cancel</button>
@@ -25,28 +25,28 @@
</kendo-grid> </kendo-grid>
<kendo-dialog *ngIf="showDialog" [title]="editing ? 'Edit Giving Type' : 'Add Giving Type'" (close)="showDialog=false" [width]="480"> <kendo-dialog *ngIf="showDialog" [title]="editing ? 'Edit Giving Type' : 'Add Giving Type'" (close)="showDialog=false" [width]="480">
<div class="form-grid"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label> <label class="flex flex-col gap-1">
Name (EN) * Name (EN) *
<kendo-textbox [(ngModel)]="form.name_en"></kendo-textbox> <kendo-textbox [(ngModel)]="form.name_en"></kendo-textbox>
</label> </label>
<label> <label class="flex flex-col gap-1">
名稱 (中) 名稱 (中)
<kendo-textbox [(ngModel)]="form.name_zh"></kendo-textbox> <kendo-textbox [(ngModel)]="form.name_zh"></kendo-textbox>
</label> </label>
<label> <label class="flex flex-col gap-1">
Description (EN) Description (EN)
<kendo-textbox [(ngModel)]="form.description_en"></kendo-textbox> <kendo-textbox [(ngModel)]="form.description_en"></kendo-textbox>
</label> </label>
<label> <label class="flex flex-col gap-1">
說明 (中) 說明 (中)
<kendo-textbox [(ngModel)]="form.description_zh"></kendo-textbox> <kendo-textbox [(ngModel)]="form.description_zh"></kendo-textbox>
</label> </label>
<label> <label class="flex flex-col gap-1">
Sort order Sort order
<kendo-numerictextbox [(ngModel)]="form.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox> <kendo-numerictextbox [(ngModel)]="form.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label> </label>
<label *ngIf="editing"> <label *ngIf="editing" class="flex items-center gap-2 md:col-span-2">
<input type="checkbox" [(ngModel)]="form.isActive" /> Active <input type="checkbox" [(ngModel)]="form.isActive" /> Active
</label> </label>
</div> </div>
@@ -17,15 +17,3 @@
gap: 0.25rem; gap: 0.25rem;
cursor: pointer; cursor: pointer;
} }
.form-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form-grid label {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
@@ -30,12 +30,12 @@
</kendo-grid> </kendo-grid>
<kendo-dialog *ngIf="showDialog" title="Add Giving" (close)="showDialog=false" [width]="520"> <kendo-dialog *ngIf="showDialog" title="Add Giving" (close)="showDialog=false" [width]="520">
<div class="form-grid"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label> <label class="flex items-center gap-2 md:col-span-2">
<input type="checkbox" [ngModel]="form.isAnonymous" (ngModelChange)="toggleAnonymous()" /> Anonymous <input type="checkbox" [ngModel]="form.isAnonymous" (ngModelChange)="toggleAnonymous()" /> Anonymous
</label> </label>
<label *ngIf="!form.isAnonymous">Giver <label *ngIf="!form.isAnonymous" class="flex flex-col gap-1 md:col-span-2">Giver
<kendo-dropdownlist [data]="memberResults" textField="displayName" valueField="id" <kendo-dropdownlist [data]="memberResults" textField="displayName" valueField="id"
[valuePrimitive]="true" [filterable]="true" [valuePrimitive]="true" [filterable]="true"
(filterChange)="onMemberFilter($event)" (filterChange)="onMemberFilter($event)"
@@ -44,34 +44,34 @@
placeholder="Search member by name"></kendo-dropdownlist> placeholder="Search member by name"></kendo-dropdownlist>
</label> </label>
<label>Type <label class="flex flex-col gap-1">Type
<kendo-dropdownlist [data]="categories" textField="name_en" valueField="id" <kendo-dropdownlist [data]="categories" textField="name_en" valueField="id"
[valuePrimitive]="true" [(ngModel)]="form.givingCategoryId"></kendo-dropdownlist> [valuePrimitive]="true" [(ngModel)]="form.givingCategoryId"></kendo-dropdownlist>
</label> </label>
<label>Payment method <label class="flex flex-col gap-1">Payment method
<kendo-dropdownlist [data]="paymentMethods" [(ngModel)]="form.paymentMethod"></kendo-dropdownlist> <kendo-dropdownlist [data]="paymentMethods" [(ngModel)]="form.paymentMethod"></kendo-dropdownlist>
</label> </label>
<label *ngIf="form.paymentMethod === 'Check'">Check # <label *ngIf="form.paymentMethod === 'Check'" class="flex flex-col gap-1">Check #
<kendo-textbox [(ngModel)]="form.checkNumber"></kendo-textbox> <kendo-textbox [(ngModel)]="form.checkNumber"></kendo-textbox>
</label> </label>
<label *ngIf="form.paymentMethod === 'Zelle'">Zelle ref <label *ngIf="form.paymentMethod === 'Zelle'" class="flex flex-col gap-1">Zelle ref
<kendo-textbox [(ngModel)]="form.zelleReferenceCode"></kendo-textbox> <kendo-textbox [(ngModel)]="form.zelleReferenceCode"></kendo-textbox>
</label> </label>
<label *ngIf="form.paymentMethod === 'PayPal'">PayPal txn <label *ngIf="form.paymentMethod === 'PayPal'" class="flex flex-col gap-1">PayPal txn
<kendo-textbox [(ngModel)]="form.payPalTransactionId"></kendo-textbox> <kendo-textbox [(ngModel)]="form.payPalTransactionId"></kendo-textbox>
</label> </label>
<label>Amount <label class="flex flex-col gap-1">Amount
<kendo-numerictextbox [(ngModel)]="form.amount" [min]="0" [format]="'c2'"></kendo-numerictextbox> <kendo-numerictextbox [(ngModel)]="form.amount" [min]="0" [format]="'c2'"></kendo-numerictextbox>
</label> </label>
<label>Date <label class="flex flex-col gap-1">Date
<kendo-datepicker [(ngModel)]="givingDateValue"></kendo-datepicker> <kendo-datepicker [(ngModel)]="givingDateValue"></kendo-datepicker>
</label> </label>
<label>Notes <label class="flex flex-col gap-1 md:col-span-2">Notes
<kendo-textbox [(ngModel)]="form.notes"></kendo-textbox> <kendo-textbox [(ngModel)]="form.notes"></kendo-textbox>
</label> </label>
</div> </div>
@@ -1,4 +1,2 @@
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; } .filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.form-grid { display: flex; flex-direction: column; gap: 0.75rem; }
.form-grid label { display: flex; flex-direction: column; gap: 0.25rem; }
@@ -120,9 +120,7 @@ export class GivingsPageComponent implements OnInit {
save(): void { save(): void {
// Sync the datepicker Date back to the ISO string field. // Sync the datepicker Date back to the ISO string field.
this.form.givingDate = this.givingDateValue this.form.givingDate = this.toIso(this.givingDateValue ?? new Date());
? this.givingDateValue.toISOString().slice(0, 10)
: new Date().toISOString().slice(0, 10);
if (this.editingId) { if (this.editingId) {
this.api.update(this.editingId, this.form).subscribe(() => { this.showDialog = false; this.load(); }); this.api.update(this.editingId, this.form).subscribe(() => { this.showDialog = false; this.load(); });
@@ -149,9 +147,18 @@ export class GivingsPageComponent implements OnInit {
checkNumber: null, checkNumber: null,
zelleReferenceCode: null, zelleReferenceCode: null,
payPalTransactionId: null, payPalTransactionId: null,
givingDate: new Date().toISOString().slice(0, 10), givingDate: this.toIso(new Date()),
isAnonymous: false, isAnonymous: false,
notes: null, notes: null,
}; };
} }
// Format using LOCAL date components — NOT toISOString(), which converts to UTC and
// rolls the date forward a day for behind-UTC users when the Date carries an evening time.
private toIso(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
} }
@@ -1,50 +1,74 @@
<div class="page"> <div class="page">
<!-- Card A — Session header -->
<section class="card">
<header class="page-header"> <header class="page-header">
<h2>Sunday Offering Entry / 主日奉獻錄入</h2> <h2 style="margin:0">Sunday Offering Entry / 主日奉獻錄入</h2>
<label>Date <label class="flex flex-col gap-1">Date
<kendo-datepicker [(ngModel)]="sessionDate" (valueChange)="checkDate()" [disabled]="editingSessionId != null"></kendo-datepicker> <kendo-datepicker [(ngModel)]="sessionDate" (valueChange)="checkDate()"
[disabled]="editingSessionId != null"></kendo-datepicker>
</label> </label>
</header> </header>
<div *ngIf="editingSessionId != null" class="edit-banner"> <div *ngIf="editingSessionId != null" class="edit-banner">
Editing submitted session — make changes and click "Update Session". <span class="badge badge--draft">Editing</span>
Editing session — make changes and click "Update Session".
<button kendoButton fillMode="flat" (click)="cancelEdit()">Cancel edit</button> <button kendoButton fillMode="flat" (click)="cancelEdit()">Cancel edit</button>
</div> </div>
<div *ngIf="dateConflict && editingSessionId == null" class="warn"> <div *ngIf="dateConflict && editingSessionId == null" class="warn">
An offering session for this date already exists. Pick another date, or reopen the existing session to edit. An offering session for this date already exists. Pick another date, or reopen the existing session to edit.
</div> </div>
</section>
<section class="entry-row"> <!-- Card B — Add giving -->
<label *ngIf="!entry.isAnonymous">Giver <section class="card">
<kendo-dropdownlist [data]="memberResults" textField="displayName" valueField="id" <h3 class="section-title">Add Giving / 錄入奉獻</h3>
[valuePrimitive]="true" [filterable]="true" <div class="grid grid-cols-1 md:grid-cols-4 gap-x-4 gap-y-3">
(filterChange)="onMemberFilter($event)" [(ngModel)]="selectedMemberId" <label class="flex flex-col gap-1 md:col-span-2">Giver
(valueChange)="onMemberSelected($event)" placeholder="Search by name"></kendo-dropdownlist> <kendo-dropdownlist *ngIf="!entry.isAnonymous" [data]="memberResults" textField="displayName" valueField="id"
[valuePrimitive]="true" [filterable]="true" (filterChange)="onMemberFilter($event)"
[(ngModel)]="selectedMemberId" (valueChange)="onMemberSelected($event)"
placeholder="Search by name"></kendo-dropdownlist>
<span *ngIf="entry.isAnonymous" class="flex items-center gap-2">
<span class="anon-chip">Anonymous</span>
<button kendoButton fillMode="flat" size="small" (click)="clearAnonymous()">Clear</button>
</span>
</label> </label>
<span *ngIf="entry.isAnonymous" class="anon-chip">Anonymous</span>
<label>Type <label class="flex flex-col gap-1">Type
<kendo-dropdownlist [data]="categories" textField="name_en" valueField="id" <kendo-dropdownlist [data]="categories" textField="name_en" valueField="id" [valuePrimitive]="true"
[valuePrimitive]="true" [(ngModel)]="entry.givingCategoryId"></kendo-dropdownlist> [(ngModel)]="entry.givingCategoryId"></kendo-dropdownlist>
</label> </label>
<label>Method <label class="flex flex-col gap-1">Method
<kendo-dropdownlist [data]="paymentMethods" [(ngModel)]="entry.paymentMethod"></kendo-dropdownlist> <kendo-dropdownlist [data]="paymentMethods" [(ngModel)]="entry.paymentMethod"></kendo-dropdownlist>
</label> </label>
<label *ngIf="entry.paymentMethod === 'Check'">Check #<kendo-textbox [(ngModel)]="entry.checkNumber"></kendo-textbox></label>
<label>Amount
<kendo-numerictextbox [(ngModel)]="entry.amount" [min]="0" [format]="'c2'" (keydown.enter)="addLine()"></kendo-numerictextbox>
</label>
<label>Notes<kendo-textbox [(ngModel)]="entry.notes" (keydown.enter)="addLine()"></kendo-textbox></label>
<div class="entry-actions"> <label *ngIf="entry.paymentMethod === 'Check'" class="flex flex-col gap-1">Check #
<kendo-textbox [(ngModel)]="entry.checkNumber"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">Amount
<kendo-numerictextbox [(ngModel)]="entry.amount" [min]="0" [format]="'c2'"
(keydown.enter)="addLine()"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">Notes
<kendo-textbox [(ngModel)]="entry.notes" (keydown.enter)="addLine()"></kendo-textbox>
</label>
<div class="flex flex-wrap gap-2 md:col-span-2">
<button kendoButton (click)="markAnonymous()">Anonymous</button> <button kendoButton (click)="markAnonymous()">Anonymous</button>
<button kendoButton (click)="showQuickAdd = true">+ Quick add member</button> <button kendoButton (click)="showQuickAdd = true">+ Quick add member</button>
<button kendoButton themeColor="primary" (click)="addLine()">+ Add (Enter)</button> <button kendoButton themeColor="primary" (click)="addLine()">{{ editingIndex !== null ? 'Update line' : '+ Add
(Enter)' }}</button>
</div>
</div> </div>
</section> </section>
<kendo-grid [data]="buffer"> <!-- Card C — Lines -->
<section class="card">
<h3 class="section-title">Lines / 已加入明細</h3>
<div *ngIf="buffer.length === 0" class="empty">No lines yet — add givings above.</div>
<kendo-grid *ngIf="buffer.length > 0" [data]="buffer">
<kendo-grid-column title="Giver"> <kendo-grid-column title="Giver">
<ng-template kendoGridCellTemplate let-l>{{ l.isAnonymous ? '(Anonymous)' : l.memberName }}</ng-template> <ng-template kendoGridCellTemplate let-l>{{ l.isAnonymous ? '(Anonymous)' : l.memberName }}</ng-template>
</kendo-grid-column> </kendo-grid-column>
@@ -59,34 +83,60 @@
</ng-template> </ng-template>
</kendo-grid-column> </kendo-grid-column>
</kendo-grid> </kendo-grid>
<div *ngIf="buffer.length > 0" class="lines-footer">Lines: {{ buffer.length }} · System total: {{ systemTotal |
currency }}</div>
</section>
<section class="reconcile"> <!-- Card D — Reconcile & submit -->
<div>Lines: {{ buffer.length }} | System total: {{ systemTotal | currency }}</div> <section class="card">
<label>Cash counted<kendo-numerictextbox [(ngModel)]="cashTotal" [min]="0" [format]="'c2'"></kendo-numerictextbox></label> <h3 class="section-title">Reconcile &amp; Submit / 對帳</h3>
<label>Check counted<kendo-numerictextbox [(ngModel)]="checkTotal" [min]="0" [format]="'c2'"></kendo-numerictextbox></label> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<div [class.ok]="difference === 0" [class.bad]="difference !== 0">Difference: {{ difference | currency }}</div> <label class="flex flex-col gap-1">Cash counted
<kendo-numerictextbox [(ngModel)]="cashTotal" [min]="0" [format]="'c2'"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1">Check counted
<kendo-numerictextbox [(ngModel)]="checkTotal" [min]="0" [format]="'c2'"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">Session notes
<kendo-textarea [(ngModel)]="notes" [rows]="2"></kendo-textarea>
</label>
</div>
<div class="flex flex-wrap items-center gap-4 mt-4">
<span>System total: {{ systemTotal | currency }}</span>
<span [class.ok]="difference === 0" [class.bad]="difference !== 0">Difference: {{ difference | currency }}</span>
<button kendoButton themeColor="primary" <button kendoButton themeColor="primary"
[disabled]="buffer.length === 0 || (editingSessionId == null && dateConflict) || submitting" [disabled]="buffer.length === 0 || (editingSessionId == null && dateConflict) || submitting"
(click)="submit()">{{ editingSessionId != null ? 'Update Session' : 'Submit' }}</button> (click)="submit()">{{ editingSessionId != null ? 'Update Session' : 'Submit' }}</button>
</div>
</section> </section>
<section class="sessions-list"> <!-- Card E — Recent sessions -->
<h3>Recent Sessions</h3> <section class="card">
<h3 class="section-title">Recent Sessions</h3>
<kendo-grid [data]="sessions"> <kendo-grid [data]="sessions">
<kendo-grid-column field="sessionDate" title="Date" [width]="120"></kendo-grid-column> <kendo-grid-column field="sessionDate" title="Date" [width]="120"></kendo-grid-column>
<kendo-grid-column field="status" title="Status" [width]="110"></kendo-grid-column> <kendo-grid-column title="Status" [width]="120">
<ng-template kendoGridCellTemplate let-s>
<span class="badge" [class.badge--draft]="s.status === 'Draft'"
[class.badge--submitted]="s.status === 'Submitted'" [class.badge--reconciled]="s.status === 'Reconciled'">{{
s.status }}</span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="lineCount" title="Lines" [width]="80"></kendo-grid-column> <kendo-grid-column field="lineCount" title="Lines" [width]="80"></kendo-grid-column>
<kendo-grid-column field="systemTotal" title="System" [width]="110" format="c2"></kendo-grid-column> <kendo-grid-column field="systemTotal" title="System" [width]="110" format="c2"></kendo-grid-column>
<kendo-grid-column field="difference" title="Diff" [width]="100" format="c2"></kendo-grid-column> <kendo-grid-column field="difference" title="Diff" [width]="100" format="c2"></kendo-grid-column>
<kendo-grid-column title="" [width]="140"> <kendo-grid-column title="" [width]="150">
<ng-template kendoGridCellTemplate let-s> <ng-template kendoGridCellTemplate let-s>
<button kendoButton fillMode="flat" *ngIf="s.status === 'Submitted'" (click)="reopenAndEdit(s)">Reopen &amp; Edit</button> <button kendoButton fillMode="flat" *ngIf="s.status === 'Submitted'" (click)="reopenAndEdit(s)">Reopen &amp;
Edit</button>
<button kendoButton fillMode="flat" *ngIf="s.status === 'Draft'" (click)="continueEditDraft(s)">Continue
editing</button>
</ng-template> </ng-template>
</kendo-grid-column> </kendo-grid-column>
</kendo-grid> </kendo-grid>
</section> </section>
<app-member-quick-add-dialog *ngIf="showQuickAdd" <app-member-quick-add-dialog *ngIf="showQuickAdd" (created)="onMemberQuickCreated($event)"
(created)="onMemberQuickCreated($event)"
(cancelled)="showQuickAdd = false"></app-member-quick-add-dialog> (cancelled)="showQuickAdd = false"></app-member-quick-add-dialog>
</div> </div>
@@ -1,11 +1,32 @@
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } .page-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; flex-wrap: wrap; }
.card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
.section-title { font-size: 1rem; font-weight: 600; margin: 0 0 0.75rem; }
.warn { background: #fff3cd; padding: 0.5rem 1rem; border-radius: 4px; margin-bottom: 1rem; } .warn { background: #fff3cd; padding: 0.5rem 1rem; border-radius: 4px; margin-bottom: 1rem; }
.entry-row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: flex-end; margin-bottom: 1rem; }
.entry-row label { display: flex; flex-direction: column; gap: 0.25rem; }
.entry-actions { display: flex; gap: 0.5rem; }
.anon-chip { padding: 0.25rem 0.5rem; background: #eee; border-radius: 4px; } .anon-chip { padding: 0.25rem 0.5rem; background: #eee; border-radius: 4px; }
.reconcile { display: flex; gap: 1rem; align-items: flex-end; margin-top: 1rem; }
.reconcile .ok { color: green; font-weight: 600; }
.reconcile .bad { color: #c00; font-weight: 600; }
.edit-banner { display: flex; align-items: center; gap: 1rem; background: #fff3cd; border-left: 4px solid #f0a500; padding: 0.5rem 1rem; border-radius: 4px; margin-bottom: 1rem; font-weight: 500; } .edit-banner { display: flex; align-items: center; gap: 1rem; background: #fff3cd; border-left: 4px solid #f0a500; padding: 0.5rem 1rem; border-radius: 4px; margin-bottom: 1rem; font-weight: 500; }
.sessions-list { margin-top: 2rem; }
.ok { color: green; font-weight: 600; }
.bad { color: #c00; font-weight: 600; }
.empty { color: #6b7280; padding: 1rem 0; }
.lines-footer { margin-top: 0.5rem; color: #374151; font-weight: 500; }
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.4;
}
.badge--draft { background: #fef3c7; color: #92400e; }
.badge--submitted { background: #dcfce7; color: #166534; }
.badge--reconciled { background: #e2e8f0; color: #334155; }
@@ -87,6 +87,15 @@ export class OfferingSessionPageComponent implements OnInit {
}); });
} }
// Already a Draft (e.g. a session reopened then left) — load it straight back in, no reopen.
continueEditDraft(s: OfferingSessionListItemDto): void {
if (s.status !== 'Draft') return;
this.api.getById(s.id).subscribe({
next: dto => this.loadIntoBuffer(dto),
error: (err: { error?: { message?: string } }) => alert(err?.error?.message ?? 'Load failed.'),
});
}
private loadIntoBuffer(dto: OfferingSessionDto): void { private loadIntoBuffer(dto: OfferingSessionDto): void {
this.editingSessionId = dto.id; this.editingSessionId = dto.id;
this.sessionDate = new Date(dto.sessionDate + 'T00:00:00'); this.sessionDate = new Date(dto.sessionDate + 'T00:00:00');
@@ -110,6 +119,8 @@ export class OfferingSessionPageComponent implements OnInit {
this.buffer = []; this.cashTotal = 0; this.checkTotal = 0; this.notes = null; this.buffer = []; this.cashTotal = 0; this.checkTotal = 0; this.notes = null;
this.sessionDate = new Date(); this.sessionDate = new Date();
this.checkDate(); this.checkDate();
// The reopened session is now a server-side Draft — refresh so its "Continue editing" appears.
this.loadSessions();
} }
onMemberFilter(term: string): void { onMemberFilter(term: string): void {
@@ -130,6 +141,10 @@ export class OfferingSessionPageComponent implements OnInit {
this.selectedMemberId = null; this.selectedMemberName = null; this.selectedMemberId = null; this.selectedMemberName = null;
} }
clearAnonymous(): void {
this.entry.isAnonymous = false;
}
addLine(): void { addLine(): void {
if (this.entry.amount <= 0) return; if (this.entry.amount <= 0) return;
if (this.entry.paymentMethod === 'Check' && !this.entry.checkNumber) return; if (this.entry.paymentMethod === 'Check' && !this.entry.checkNumber) return;
@@ -221,5 +236,12 @@ export class OfferingSessionPageComponent implements OnInit {
}; };
} }
private toIso(d: Date): string { return d.toISOString().slice(0, 10); } // Format using LOCAL date components — NOT toISOString(), which converts to UTC and
// rolls the date forward a day for behind-UTC users when the Date carries an evening time.
private toIso(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
} }
@@ -12,7 +12,7 @@
<div class="branding-section"> <div class="branding-section">
<div class="branding-content"> <div class="branding-content">
<div class="logo-container"> <div class="logo-container">
<img src="assets/rbj-logo.svg" alt="RBJ Logo" class="logo-image"> <img src="assets/images/ROLCC-Logo-Color.png" alt="ROLCC Logo" class="logo-image">
<div class="logo-text"> <div class="logo-text">
<h1>ROLCC AC</h1> <h1>ROLCC AC</h1>
<span class="tagline">Church Management Portal</span> <span class="tagline">Church Management Portal</span>
@@ -5,7 +5,8 @@
<!-- TAB 1: Basic Info --> <!-- TAB 1: Basic Info -->
<kendo-tabstrip-tab title="Basic Info" [selected]="true"> <kendo-tabstrip-tab title="Basic Info" [selected]="true">
<ng-template kendoTabContent> <ng-template kendoTabContent>
<div class="k-form k-form-horizontal k-mt-4"> <div class="k-form k-form-vertical k-mt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<kendo-formfield> <kendo-formfield>
<kendo-label text="Legal First Name *"></kendo-label> <kendo-label text="Legal First Name *"></kendo-label>
@@ -17,7 +18,7 @@
<kendo-textbox formControlName="lastName_en"></kendo-textbox> <kendo-textbox formControlName="lastName_en"></kendo-textbox>
</kendo-formfield> </kendo-formfield>
<kendo-formfield> <kendo-formfield class="md:col-span-2">
<kendo-label text="Nick Name (Common Name)"></kendo-label> <kendo-label text="Nick Name (Common Name)"></kendo-label>
<kendo-textbox formControlName="nickName" placeholder="e.g. Chris"></kendo-textbox> <kendo-textbox formControlName="nickName" placeholder="e.g. Chris"></kendo-textbox>
</kendo-formfield> </kendo-formfield>
@@ -64,15 +65,17 @@
</kendo-formfield> </kendo-formfield>
</div> </div>
</div>
</ng-template> </ng-template>
</kendo-tabstrip-tab> </kendo-tabstrip-tab>
<!-- TAB 2: Contact --> <!-- TAB 2: Contact -->
<kendo-tabstrip-tab title="Contact"> <kendo-tabstrip-tab title="Contact">
<ng-template kendoTabContent> <ng-template kendoTabContent>
<div class="k-form k-form-horizontal k-mt-4"> <div class="k-form k-form-vertical k-mt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<kendo-formfield> <kendo-formfield class="md:col-span-2">
<kendo-label text="Email"></kendo-label> <kendo-label text="Email"></kendo-label>
<kendo-textbox formControlName="email"></kendo-textbox> <kendo-textbox formControlName="email"></kendo-textbox>
</kendo-formfield> </kendo-formfield>
@@ -87,7 +90,7 @@
<kendo-textbox formControlName="phoneHome"></kendo-textbox> <kendo-textbox formControlName="phoneHome"></kendo-textbox>
</kendo-formfield> </kendo-formfield>
<kendo-formfield> <kendo-formfield class="md:col-span-2">
<kendo-label text="Address"></kendo-label> <kendo-label text="Address"></kendo-label>
<kendo-textbox formControlName="address"></kendo-textbox> <kendo-textbox formControlName="address"></kendo-textbox>
</kendo-formfield> </kendo-formfield>
@@ -113,13 +116,15 @@
</kendo-formfield> </kendo-formfield>
</div> </div>
</div>
</ng-template> </ng-template>
</kendo-tabstrip-tab> </kendo-tabstrip-tab>
<!-- TAB 3: Church Info --> <!-- TAB 3: Church Info -->
<kendo-tabstrip-tab title="Church Info"> <kendo-tabstrip-tab title="Church Info">
<ng-template kendoTabContent> <ng-template kendoTabContent>
<div class="k-form k-form-horizontal k-mt-4"> <div class="k-form k-form-vertical k-mt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<kendo-formfield> <kendo-formfield>
<kendo-label text="Join Date"></kendo-label> <kendo-label text="Join Date"></kendo-label>
@@ -131,17 +136,18 @@
<kendo-datepicker formControlName="baptismDate"></kendo-datepicker> <kendo-datepicker formControlName="baptismDate"></kendo-datepicker>
</kendo-formfield> </kendo-formfield>
<kendo-formfield> <kendo-formfield class="md:col-span-2">
<kendo-label text="Baptism Church"></kendo-label> <kendo-label text="Baptism Church"></kendo-label>
<kendo-textbox formControlName="baptismChurch"></kendo-textbox> <kendo-textbox formControlName="baptismChurch"></kendo-textbox>
</kendo-formfield> </kendo-formfield>
<kendo-formfield> <kendo-formfield class="md:col-span-2">
<kendo-label text="Notes"></kendo-label> <kendo-label text="Notes"></kendo-label>
<kendo-textarea formControlName="notes" [rows]="4"></kendo-textarea> <kendo-textarea formControlName="notes" [rows]="4"></kendo-textarea>
</kendo-formfield> </kendo-formfield>
</div> </div>
</div>
</ng-template> </ng-template>
</kendo-tabstrip-tab> </kendo-tabstrip-tab>
@@ -1,5 +1,6 @@
<kendo-dialog title="Add New User" (close)="onCancel()" [minWidth]="460" [width]="500"> <kendo-dialog title="Add New User" (close)="onCancel()" [minWidth]="460" [width]="500">
<form [formGroup]="form" class="k-form k-form-vertical k-p-2"> <form [formGroup]="form" class="k-form k-form-vertical k-p-2">
<div class="grid grid-cols-1 gap-y-3">
<kendo-formfield> <kendo-formfield>
<kendo-label text="Email *"></kendo-label> <kendo-label text="Email *"></kendo-label>
@@ -8,14 +9,14 @@
<kendo-formerror *ngIf="form.get('email')?.errors?.['email']">Invalid email.</kendo-formerror> <kendo-formerror *ngIf="form.get('email')?.errors?.['email']">Invalid email.</kendo-formerror>
</kendo-formfield> </kendo-formfield>
<kendo-formfield class="k-mt-3"> <kendo-formfield>
<kendo-label text="Roles *"></kendo-label> <kendo-label text="Roles *"></kendo-label>
<kendo-multiselect formControlName="roles" [data]="roleOptions" <kendo-multiselect formControlName="roles" [data]="roleOptions"
placeholder="Select roles"></kendo-multiselect> placeholder="Select roles"></kendo-multiselect>
<kendo-formerror>At least one role is required.</kendo-formerror> <kendo-formerror>At least one role is required.</kendo-formerror>
</kendo-formfield> </kendo-formfield>
<kendo-formfield class="k-mt-3"> <kendo-formfield>
<kendo-label text="Language"></kendo-label> <kendo-label text="Language"></kendo-label>
<kendo-dropdownlist formControlName="languagePreference" <kendo-dropdownlist formControlName="languagePreference"
[data]="langOptions" textField="text" valueField="value" [data]="langOptions" textField="text" valueField="value"
@@ -23,12 +24,13 @@
</kendo-dropdownlist> </kendo-dropdownlist>
</kendo-formfield> </kendo-formfield>
<kendo-formfield class="k-mt-3"> <kendo-formfield>
<kendo-label text="Member ID (optional)"></kendo-label> <kendo-label text="Member ID (optional)"></kendo-label>
<kendo-numerictextbox formControlName="memberId" [format]="'0'" <kendo-numerictextbox formControlName="memberId" [format]="'0'"
placeholder="Link to a member record"></kendo-numerictextbox> placeholder="Link to a member record"></kendo-numerictextbox>
</kendo-formfield> </kendo-formfield>
</div>
</form> </form>
<kendo-dialog-actions> <kendo-dialog-actions>
@@ -1,5 +1,6 @@
<kendo-dialog title="Edit User" (close)="onCancel()" [minWidth]="460" [width]="500"> <kendo-dialog title="Edit User" (close)="onCancel()" [minWidth]="460" [width]="500">
<form [formGroup]="form" class="k-form k-form-vertical k-p-2"> <form [formGroup]="form" class="k-form k-form-vertical k-p-2">
<div class="grid grid-cols-1 gap-y-3">
<kendo-formfield> <kendo-formfield>
<kendo-label text="Email *"></kendo-label> <kendo-label text="Email *"></kendo-label>
@@ -8,13 +9,13 @@
<kendo-formerror *ngIf="form.get('email')?.errors?.['email']">Invalid email.</kendo-formerror> <kendo-formerror *ngIf="form.get('email')?.errors?.['email']">Invalid email.</kendo-formerror>
</kendo-formfield> </kendo-formfield>
<kendo-formfield class="k-mt-3"> <kendo-formfield>
<kendo-label text="Roles *"></kendo-label> <kendo-label text="Roles *"></kendo-label>
<kendo-multiselect formControlName="roles" [data]="roleOptions" <kendo-multiselect formControlName="roles" [data]="roleOptions"
placeholder="Select roles"></kendo-multiselect> placeholder="Select roles"></kendo-multiselect>
</kendo-formfield> </kendo-formfield>
<kendo-formfield class="k-mt-3"> <kendo-formfield>
<kendo-label text="Language"></kendo-label> <kendo-label text="Language"></kendo-label>
<kendo-dropdownlist formControlName="languagePreference" <kendo-dropdownlist formControlName="languagePreference"
[data]="langOptions" textField="text" valueField="value" [data]="langOptions" textField="text" valueField="value"
@@ -22,11 +23,12 @@
</kendo-dropdownlist> </kendo-dropdownlist>
</kendo-formfield> </kendo-formfield>
<div class="k-d-flex k-align-items-center k-gap-2 k-mt-4"> <div class="flex items-center gap-2">
<input kendoCheckBox type="checkbox" formControlName="isActive" id="isActiveCheck" /> <input kendoCheckBox type="checkbox" formControlName="isActive" id="isActiveCheck" />
<kendo-label for="isActiveCheck" text="Account Active"></kendo-label> <kendo-label for="isActiveCheck" text="Account Active"></kendo-label>
</div> </div>
</div>
</form> </form>
<kendo-dialog-actions> <kendo-dialog-actions>
@@ -16,10 +16,10 @@
<aside class="sidebar" [class.collapsed]="sidebarCollapsed"> <aside class="sidebar" [class.collapsed]="sidebarCollapsed">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="logo-section"> <div class="logo-section">
<img src="assets/rbj-logo.svg" alt="RBJ Logo" class="logo-image"> <img src="assets/images/ROLCC-Logo-Color.png" alt="ROLCC Logo" class="logo-image">
<div class="logo-text" *ngIf="!sidebarCollapsed"> <div class="logo-text" *ngIf="!sidebarCollapsed">
<h2>ROLCC AC</h2> <h2>ROLCC AC</h2>
<span class="tagline">Escrow Portal</span> <span class="tagline">Church Portal</span>
</div> </div>
</div> </div>
<button class="sidebar-toggle" (click)="toggleSidebar()" title="Toggle sidebar"> <button class="sidebar-toggle" (click)="toggleSidebar()" title="Toggle sidebar">
@@ -1,7 +1,7 @@
<div class="token-verification-container"> <div class="token-verification-container">
<div class="verification-card"> <div class="verification-card">
<div class="logo-section"> <div class="logo-section">
<img src="assets/rbj-logo.svg" alt="RBJ Logo" class="logo"> <img src="assets/images/ROLCC-Logo-Color.png" alt="ROLCC Logo" class="logo">
</div> </div>
<div class="verification-content"> <div class="verification-content">
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+19
View File
@@ -0,0 +1,19 @@
/*
* Tailwind v4 — utilities + theme only. NO preflight: Kendo UI owns base element
* styling and styles.scss already ships its own reset. Loading Tailwind's base reset
* would fight Kendo.
*
* theme -> kept in its own layer (design tokens / CSS vars only, no output by itself)
* utilities -> imported UNLAYERED so layout utilities (grid/col-span/gap/flex) reliably
* win over Kendo's unlayered CDN theme when they ever land on the same node.
*
* Used by the shared form-layout convention: a neutral wrapper <div> carries
* grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3
* and full-width fields use md:col-span-2.
*/
@layer theme;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css";
/* Explicit source scanning (auto-detection also covers these). */
@source "./";
+4 -4
View File
@@ -1315,12 +1315,12 @@ assets/i18n/zh-TW.json
## 7. 開發階段規劃 (Roadmap) ## 7. 開發階段規劃 (Roadmap)
### Phase 0 — 技術基礎 & DevOps (23 週) ### Phase 0 — 技術基礎 & DevOps (23 週)
- [ ] Azure VM 建置 + Docker 環境 - [X] Azure VM 建置 + Docker 環境
- [ ] Gitea + Jenkins 啟動,CI pipeline 通 - [ ] Gitea + Jenkins 啟動,CI pipeline 通
- [ ] Angular + Capacitor 專案骨架(含 ngx-translate en/zh-TW - [ ] Angular + Capacitor 專案骨架(含 ngx-translate en/zh-TW
- [ ] ASP.NET Core API 骨架 + EF Core 初始 Migration - [X] ASP.NET Core API 骨架 + EF Core 初始 Migration
- [ ] 認證系統(JWT + Refresh Token + ASP.NET Identity - [X] 認證系統(JWT + Refresh Token + ASP.NET Identity
- [ ] RBAC 框架(角色 + Ministry Scope middleware - [X] RBAC 框架(角色 + Ministry Scope middleware
- [ ] Audit Log 基礎建設 - [ ] Audit Log 基礎建設
- [ ] Mobile-first UI 元件庫設定(底部導覽、touch target 規範) - [ ] Mobile-first UI 元件庫設定(底部導覽、touch target 規範)