rental property started
This commit is contained in:
parent
19678b3b22
commit
bfa90b7377
731
assets/style.css
731
assets/style.css
@ -4303,10 +4303,24 @@ body {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-action-button--center {
|
.list-header-center {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
right: auto;
|
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header-center .list-action-button {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-action-button--small {
|
||||||
|
padding: 0.35rem 0.85rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-offset {
|
.dialog-offset {
|
||||||
@ -4620,6 +4634,712 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dashboard layout (landlord + tenant) – no Tailwind */
|
||||||
|
.dashboard-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--theme-bg);
|
||||||
|
}
|
||||||
|
.dashboard-header {
|
||||||
|
background: var(--theme-surface);
|
||||||
|
box-shadow: var(--theme-shadow);
|
||||||
|
}
|
||||||
|
.dashboard-header-inner {
|
||||||
|
max-width: 80rem;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.dashboard-header-inner {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.dashboard-header-inner {
|
||||||
|
padding-left: 2rem;
|
||||||
|
padding-right: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dashboard-header-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.dashboard-header-title {
|
||||||
|
font-size: 1.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-text);
|
||||||
|
}
|
||||||
|
.dashboard-header-subtitle {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--theme-muted);
|
||||||
|
}
|
||||||
|
.dashboard-header-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.dashboard-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 8.5rem;
|
||||||
|
flex: 0 0 8.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-width: 2px;
|
||||||
|
border-style: solid;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.dashboard-btn--primary {
|
||||||
|
border-color: transparent;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--theme-primary-fill);
|
||||||
|
}
|
||||||
|
.dashboard-btn--primary:hover {
|
||||||
|
background: var(--theme-primary-fill-hover);
|
||||||
|
}
|
||||||
|
.dashboard-btn--secondary {
|
||||||
|
border-color: var(--theme-border);
|
||||||
|
color: var(--theme-text);
|
||||||
|
background: var(--theme-surface);
|
||||||
|
}
|
||||||
|
.dashboard-btn--secondary:hover {
|
||||||
|
background: var(--landlord-surface-subtle);
|
||||||
|
}
|
||||||
|
.dashboard-btn--messages-new {
|
||||||
|
border-color: var(--theme-accent);
|
||||||
|
color: var(--theme-text);
|
||||||
|
background: var(--theme-surface);
|
||||||
|
box-shadow: var(--theme-shadow);
|
||||||
|
outline: 2px solid rgba(15, 109, 183, 0.3);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.dashboard-btn--messages-new:hover {
|
||||||
|
background: var(--landlord-surface-subtle);
|
||||||
|
}
|
||||||
|
.dashboard-content {
|
||||||
|
max-width: 80rem;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
padding: 1rem 1rem 2rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.dashboard-content {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.dashboard-content {
|
||||||
|
padding-left: 2rem;
|
||||||
|
padding-right: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dashboard-stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.dashboard-stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.dashboard-stats-grid {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dashboard-two-col {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.dashboard-two-col {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dashboard-card--full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.dashboard-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.dashboard-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.dashboard-table thead {
|
||||||
|
background: var(--landlord-surface-subtle);
|
||||||
|
}
|
||||||
|
.dashboard-table th,
|
||||||
|
.dashboard-table td {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.dashboard-table-th {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--theme-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.dashboard-table-td {
|
||||||
|
color: var(--theme-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dashboard-table tbody tr {
|
||||||
|
border-bottom: 1px solid var(--theme-border);
|
||||||
|
}
|
||||||
|
.dashboard-table tbody tr:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.dashboard-table-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
color: var(--theme-muted);
|
||||||
|
}
|
||||||
|
.dashboard-card {
|
||||||
|
background: var(--theme-surface);
|
||||||
|
box-shadow: var(--theme-shadow);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.dashboard-card--no-overflow {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.dashboard-card-body {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.dashboard-card-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dashboard-stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.dashboard-stat-icon-wrap {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dashboard-stat-icon {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
color: var(--theme-muted);
|
||||||
|
}
|
||||||
|
.dashboard-stat-content {
|
||||||
|
margin-left: 1.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.dashboard-stat-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--theme-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.dashboard-stat-value {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--theme-text);
|
||||||
|
}
|
||||||
|
.dashboard-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.dashboard-section-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--theme-text);
|
||||||
|
}
|
||||||
|
.dashboard-section-title--mb {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.dashboard-property-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.dashboard-property-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.dashboard-property-grid {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dashboard-section-link {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--theme-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.dashboard-section-link:hover {
|
||||||
|
color: var(--theme-accent-2);
|
||||||
|
}
|
||||||
|
.dashboard-list-spaced > * + * {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.dashboard-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--landlord-surface-subtle);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.dashboard-row--yellow {
|
||||||
|
background: rgba(254, 249, 195, 0.6);
|
||||||
|
}
|
||||||
|
.dashboard-row--blue {
|
||||||
|
background: rgba(219, 234, 254, 0.6);
|
||||||
|
}
|
||||||
|
.dashboard-row--red {
|
||||||
|
background: rgba(254, 226, 226, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:is(.landlord-route, .tenant-route) .dashboard-row--yellow {
|
||||||
|
background: rgba(120, 100, 40, 0.5);
|
||||||
|
}
|
||||||
|
:is(.landlord-route, .tenant-route) .dashboard-row--blue {
|
||||||
|
background: rgba(30, 58, 100, 0.55);
|
||||||
|
}
|
||||||
|
:is(.landlord-route, .tenant-route) .dashboard-row--red {
|
||||||
|
background: rgba(100, 40, 40, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme-mode="dark"]
|
||||||
|
:is(.landlord-route, .tenant-route)
|
||||||
|
.dashboard-row--yellow {
|
||||||
|
background: rgba(120, 100, 40, 0.5);
|
||||||
|
}
|
||||||
|
:root[data-theme-mode="dark"]
|
||||||
|
:is(.landlord-route, .tenant-route)
|
||||||
|
.dashboard-row--blue {
|
||||||
|
background: rgba(30, 58, 100, 0.55);
|
||||||
|
}
|
||||||
|
:root[data-theme-mode="dark"]
|
||||||
|
:is(.landlord-route, .tenant-route)
|
||||||
|
.dashboard-row--red {
|
||||||
|
background: rgba(100, 40, 40, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-row-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--theme-text);
|
||||||
|
}
|
||||||
|
.dashboard-row-muted {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--theme-muted);
|
||||||
|
}
|
||||||
|
.dashboard-row-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--theme-muted);
|
||||||
|
}
|
||||||
|
.dashboard-row-meta.capitalize,
|
||||||
|
.dashboard-row-muted.capitalize {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.dashboard-approval-details {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.dashboard-approval-details .dashboard-row-muted + .dashboard-row-meta,
|
||||||
|
.dashboard-approval-details .dashboard-row-label + .dashboard-row-muted {
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
.dashboard-approval-details .dashboard-row-muted + .dashboard-row-muted,
|
||||||
|
.dashboard-approval-details .dashboard-row-meta + .dashboard-row-muted {
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
}
|
||||||
|
.dashboard-row-value {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--theme-text);
|
||||||
|
}
|
||||||
|
.dashboard-row-sub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--theme-muted);
|
||||||
|
}
|
||||||
|
.dashboard-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
.dashboard-empty-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--theme-muted);
|
||||||
|
}
|
||||||
|
.dashboard-empty-link {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--theme-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.dashboard-empty-link:hover {
|
||||||
|
color: var(--theme-accent-2);
|
||||||
|
}
|
||||||
|
.dashboard-message-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.dashboard-message-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--theme-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.dashboard-message-avatar-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--theme-text);
|
||||||
|
}
|
||||||
|
.dashboard-message-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.dashboard-message-from {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--theme-text);
|
||||||
|
}
|
||||||
|
.dashboard-message-preview {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--theme-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.dashboard-message-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--theme-muted);
|
||||||
|
}
|
||||||
|
.dashboard-message-dot {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--theme-accent);
|
||||||
|
}
|
||||||
|
.dashboard-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.dashboard-badge--green {
|
||||||
|
background: rgba(187, 247, 208, 0.8);
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
.dashboard-badge--green:hover {
|
||||||
|
background: rgba(134, 239, 172, 0.9);
|
||||||
|
}
|
||||||
|
.dashboard-badge--red {
|
||||||
|
background: rgba(254, 226, 226, 0.8);
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
.dashboard-badge--red:hover {
|
||||||
|
background: rgba(252, 165, 165, 0.9);
|
||||||
|
}
|
||||||
|
.dashboard-badge--yellow {
|
||||||
|
background: rgba(254, 249, 195, 0.8);
|
||||||
|
color: #854d0e;
|
||||||
|
}
|
||||||
|
.dashboard-actions-inline {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.dashboard-actions-inline .dashboard-badge {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.dashboard-quick-action {
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.dashboard-quick-action:hover .dashboard-card {
|
||||||
|
box-shadow: 0 20px 50px rgba(15, 38, 70, 0.2);
|
||||||
|
}
|
||||||
|
.dashboard-stat-icon--green {
|
||||||
|
color: var(--theme-success);
|
||||||
|
}
|
||||||
|
.dashboard-stat-icon--blue {
|
||||||
|
color: var(--theme-accent);
|
||||||
|
}
|
||||||
|
.dashboard-stat-icon--purple {
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
.dashboard-stat-icon--yellow {
|
||||||
|
color: #ca8a04;
|
||||||
|
}
|
||||||
|
.dashboard-badge--red-pill {
|
||||||
|
padding: 0.125rem 0.625rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgba(254, 226, 226, 0.8);
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.dashboard-error-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--theme-bg);
|
||||||
|
}
|
||||||
|
.dashboard-error-inner {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.dashboard-error-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-text);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.dashboard-error-text {
|
||||||
|
color: var(--theme-muted);
|
||||||
|
}
|
||||||
|
.dashboard-error-link {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--theme-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.dashboard-error-link:hover {
|
||||||
|
color: var(--theme-accent-2);
|
||||||
|
}
|
||||||
|
.dashboard-section-mb {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tenant notification center */
|
||||||
|
.dashboard-notification-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.dashboard-notification-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--landlord-surface-subtle);
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-muted);
|
||||||
|
}
|
||||||
|
.dashboard-notification-grid {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.dashboard-notification-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dashboard-notification-card {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--theme-surface);
|
||||||
|
}
|
||||||
|
.dashboard-notification-card--highlight {
|
||||||
|
border-color: rgba(245, 158, 11, 0.5);
|
||||||
|
background: rgba(255, 251, 235, 0.8);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:is(.landlord-route, .tenant-route) .dashboard-notification-card--highlight {
|
||||||
|
border-color: rgba(180, 83, 9, 0.5);
|
||||||
|
background: rgba(69, 52, 24, 0.55);
|
||||||
|
}
|
||||||
|
:is(.landlord-route, .tenant-route) .dashboard-notification-icon--highlight {
|
||||||
|
background: rgba(69, 52, 24, 0.7);
|
||||||
|
color: var(--theme-accent);
|
||||||
|
}
|
||||||
|
:is(.landlord-route, .tenant-route) .dashboard-notification-count--highlight {
|
||||||
|
color: var(--theme-accent);
|
||||||
|
}
|
||||||
|
:is(.landlord-route, .tenant-route) .dashboard-notification-dot {
|
||||||
|
outline-color: rgba(69, 52, 24, 0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme-mode="dark"]
|
||||||
|
:is(.landlord-route, .tenant-route)
|
||||||
|
.dashboard-notification-card--highlight {
|
||||||
|
border-color: rgba(180, 83, 9, 0.5);
|
||||||
|
background: rgba(69, 52, 24, 0.55);
|
||||||
|
}
|
||||||
|
:root[data-theme-mode="dark"]
|
||||||
|
:is(.landlord-route, .tenant-route)
|
||||||
|
.dashboard-notification-icon--highlight {
|
||||||
|
background: rgba(69, 52, 24, 0.7);
|
||||||
|
color: var(--theme-accent);
|
||||||
|
}
|
||||||
|
:root[data-theme-mode="dark"]
|
||||||
|
:is(.landlord-route, .tenant-route)
|
||||||
|
.dashboard-notification-count--highlight {
|
||||||
|
color: var(--theme-accent);
|
||||||
|
}
|
||||||
|
:root[data-theme-mode="dark"]
|
||||||
|
:is(.landlord-route, .tenant-route)
|
||||||
|
.dashboard-notification-dot {
|
||||||
|
outline-color: rgba(69, 52, 24, 0.9);
|
||||||
|
}
|
||||||
|
.dashboard-notification-card-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.dashboard-notification-icon-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.dashboard-notification-icon {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--landlord-surface-subtle);
|
||||||
|
color: var(--theme-muted);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.dashboard-notification-icon--highlight {
|
||||||
|
background: rgba(254, 243, 199, 0.9);
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
.dashboard-notification-icon--blue {
|
||||||
|
background: rgba(219, 234, 254, 0.8);
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
.dashboard-notification-icon--green {
|
||||||
|
background: rgba(209, 250, 229, 0.8);
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
.dashboard-notification-icon-svg {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
.dashboard-notification-dot {
|
||||||
|
position: absolute;
|
||||||
|
right: -2px;
|
||||||
|
top: -2px;
|
||||||
|
width: 0.625rem;
|
||||||
|
height: 0.625rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #f59e0b;
|
||||||
|
outline: 2px solid rgba(255, 251, 235, 0.9);
|
||||||
|
outline-offset: 0;
|
||||||
|
}
|
||||||
|
.dashboard-notification-count {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.dashboard-notification-count--highlight {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
.dashboard-notification-count--blue {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
.dashboard-notification-count--green {
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
.dashboard-notification-footer {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.dashboard-notification-footer a {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.dashboard-notification-footer a:hover {
|
||||||
|
color: var(--theme-accent-2);
|
||||||
|
}
|
||||||
|
.dashboard-notification-footer a.notification-link--blue {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
.dashboard-notification-footer a.notification-link--blue:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
.dashboard-notification-footer a.notification-link--green {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
.dashboard-notification-footer a.notification-link--green:hover {
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
.dashboard-invite-list {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.dashboard-invite-list > * + * {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.dashboard-invite-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--landlord-surface-subtle);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
.dashboard-invite-item-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--theme-text);
|
||||||
|
}
|
||||||
|
.dashboard-invite-item-address {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--theme-muted);
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
.dashboard-invite-link {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dashboard-invite-link:hover {
|
||||||
|
color: var(--theme-accent-2);
|
||||||
|
}
|
||||||
|
|
||||||
.welcome-logo {
|
.welcome-logo {
|
||||||
left: 70%;
|
left: 70%;
|
||||||
}
|
}
|
||||||
@ -5025,6 +5745,13 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
.theme-dialog-submit-button--danger {
|
||||||
|
background: var(--theme-danger, #b91c1c);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.theme-dialog-submit-button--danger:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
.theme-dialog-cancel-button {
|
.theme-dialog-cancel-button {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
* Entry point for the compiled server binary.
|
* Entry point for the compiled server binary.
|
||||||
* Sets FRESH_ROOT so the server finds _fresh/client and _fresh/server next to the binary, then starts Deno.serve.
|
* Sets FRESH_ROOT so the server finds _fresh/client and _fresh/server next to the binary, then starts Deno.serve.
|
||||||
*/
|
*/
|
||||||
import { join, dirname } from "https://deno.land/std@0.224.0/path/mod.ts";
|
import { dirname, join } from "https://deno.land/std@0.224.0/path/mod.ts";
|
||||||
|
|
||||||
// Must run before importing the server so the bundle uses this root at load time
|
// Must run before importing the server so the bundle uses this root at load time
|
||||||
const binDir = dirname(Deno.execPath());
|
const binDir = dirname(Deno.execPath());
|
||||||
|
|||||||
205
claude_code_zai_env.sh
Normal file
205
claude_code_zai_env.sh
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ========================
|
||||||
|
# Define Constants
|
||||||
|
# ========================
|
||||||
|
SCRIPT_NAME=$(basename "$0")
|
||||||
|
NODE_MIN_VERSION=18
|
||||||
|
NODE_INSTALL_VERSION=22
|
||||||
|
NVM_VERSION="v0.40.3"
|
||||||
|
CLAUDE_PACKAGE="@anthropic-ai/claude-code"
|
||||||
|
CONFIG_DIR="$HOME/.claude"
|
||||||
|
CONFIG_FILE="$CONFIG_DIR/settings.json"
|
||||||
|
API_BASE_URL="https://api.z.ai/api/anthropic"
|
||||||
|
API_KEY_URL="https://z.ai/manage-apikey/apikey-list"
|
||||||
|
API_TIMEOUT_MS=3000000
|
||||||
|
|
||||||
|
# ========================
|
||||||
|
# Functions
|
||||||
|
# ========================
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
echo "🔹 $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
echo "✅ $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo "❌ $*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_dir_exists() {
|
||||||
|
local dir="$1"
|
||||||
|
if [ ! -d "$dir" ]; then
|
||||||
|
mkdir -p "$dir" || {
|
||||||
|
log_error "Failed to create directory: $dir"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========================
|
||||||
|
# Node.js Installation
|
||||||
|
# ========================
|
||||||
|
|
||||||
|
install_nodejs() {
|
||||||
|
local platform=$(uname -s)
|
||||||
|
|
||||||
|
case "$platform" in
|
||||||
|
Linux|Darwin)
|
||||||
|
log_info "Installing Node.js on $platform..."
|
||||||
|
|
||||||
|
# Install nvm
|
||||||
|
log_info "Installing nvm ($NVM_VERSION)..."
|
||||||
|
curl -s https://raw.githubusercontent.com/nvm-sh/nvm/"$NVM_VERSION"/install.sh | bash
|
||||||
|
|
||||||
|
# Load nvm
|
||||||
|
log_info "Loading nvm environment..."
|
||||||
|
\. "$HOME/.nvm/nvm.sh"
|
||||||
|
|
||||||
|
# Install Node.js
|
||||||
|
log_info "Installing Node.js $NODE_INSTALL_VERSION..."
|
||||||
|
nvm install "$NODE_INSTALL_VERSION"
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
node -v &>/dev/null || {
|
||||||
|
log_error "Node.js installation failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
log_success "Node.js installed: $(node -v)"
|
||||||
|
log_success "npm version: $(npm -v)"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "Unsupported platform: $platform"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========================
|
||||||
|
# Node.js Check
|
||||||
|
# ========================
|
||||||
|
|
||||||
|
check_nodejs() {
|
||||||
|
if command -v node &>/dev/null; then
|
||||||
|
current_version=$(node -v | sed 's/v//')
|
||||||
|
major_version=$(echo "$current_version" | cut -d. -f1)
|
||||||
|
|
||||||
|
if [ "$major_version" -ge "$NODE_MIN_VERSION" ]; then
|
||||||
|
log_success "Node.js is already installed: v$current_version"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_info "Node.js v$current_version is installed but version < $NODE_MIN_VERSION. Upgrading..."
|
||||||
|
install_nodejs
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_info "Node.js not found. Installing..."
|
||||||
|
install_nodejs
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========================
|
||||||
|
# Claude Code Installation
|
||||||
|
# ========================
|
||||||
|
|
||||||
|
install_claude_code() {
|
||||||
|
if command -v claude &>/dev/null; then
|
||||||
|
log_success "Claude Code is already installed: $(claude --version)"
|
||||||
|
else
|
||||||
|
log_info "Installing Claude Code..."
|
||||||
|
npm install -g "$CLAUDE_PACKAGE" || {
|
||||||
|
log_error "Failed to install claude-code"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
log_success "Claude Code installed successfully"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_claude_json(){
|
||||||
|
node --eval '
|
||||||
|
const os = require("os");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const homeDir = os.homedir();
|
||||||
|
const filePath = path.join(homeDir, ".claude.json");
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const content = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify({ ...content, hasCompletedOnboarding: true }, null, 2), "utf-8");
|
||||||
|
} else {
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify({ hasCompletedOnboarding: true }, null, 2), "utf-8");
|
||||||
|
}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========================
|
||||||
|
# API Key Configuration
|
||||||
|
# ========================
|
||||||
|
|
||||||
|
configure_claude() {
|
||||||
|
log_info "Configuring Claude Code..."
|
||||||
|
echo " You can get your API key from: $API_KEY_URL"
|
||||||
|
read -s -p "🔑 Please enter your Z.AI API key: " api_key
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [ -z "$api_key" ]; then
|
||||||
|
log_error "API key cannot be empty. Please run the script again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ensure_dir_exists "$CONFIG_DIR"
|
||||||
|
|
||||||
|
# Write settings.json
|
||||||
|
node --eval '
|
||||||
|
const os = require("os");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const homeDir = os.homedir();
|
||||||
|
const filePath = path.join(homeDir, ".claude", "settings.json");
|
||||||
|
const apiKey = "'"$api_key"'";
|
||||||
|
|
||||||
|
const content = fs.existsSync(filePath)
|
||||||
|
? JSON.parse(fs.readFileSync(filePath, "utf-8"))
|
||||||
|
: {};
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify({
|
||||||
|
...content,
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||||
|
ANTHROPIC_BASE_URL: "'"$API_BASE_URL"'",
|
||||||
|
API_TIMEOUT_MS: "'"$API_TIMEOUT_MS"'",
|
||||||
|
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1
|
||||||
|
}
|
||||||
|
}, null, 2), "utf-8");
|
||||||
|
' || {
|
||||||
|
log_error "Failed to write settings.json"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success "Claude Code configured successfully"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========================
|
||||||
|
# Main
|
||||||
|
# ========================
|
||||||
|
|
||||||
|
main() {
|
||||||
|
echo "🚀 Starting $SCRIPT_NAME"
|
||||||
|
|
||||||
|
check_nodejs
|
||||||
|
install_claude_code
|
||||||
|
configure_claude_json
|
||||||
|
configure_claude
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log_success "🎉 Installation completed successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "🚀 You can now start using Claude Code with:"
|
||||||
|
echo " claude"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@ -273,6 +273,33 @@ export interface MaintenanceRequest {
|
|||||||
resolved_on?: number | null;
|
resolved_on?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PropertyExpense {
|
||||||
|
id: string;
|
||||||
|
property_id?: string | null;
|
||||||
|
category?: string | null;
|
||||||
|
amount?: number | null;
|
||||||
|
description?: string | null;
|
||||||
|
expense_date?: number | null;
|
||||||
|
payment_method?: string | null;
|
||||||
|
vendor?: string | null;
|
||||||
|
receipt_url?: string | null;
|
||||||
|
is_recurring?: boolean | null;
|
||||||
|
recurring_frequency?: string | null;
|
||||||
|
created_on?: number;
|
||||||
|
updated_on?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RentChange {
|
||||||
|
id: string;
|
||||||
|
property_id?: string | null;
|
||||||
|
effective_date?: number | null;
|
||||||
|
monthly_rent?: number | null;
|
||||||
|
reason?: string | null;
|
||||||
|
previous_rent?: number | null;
|
||||||
|
created_by?: string | null;
|
||||||
|
created_on?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserPin {
|
export interface UserPin {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
pin: string;
|
pin: string;
|
||||||
|
|||||||
33
db/schema.ts
33
db/schema.ts
@ -274,6 +274,35 @@ export const payment_transactions = pgTable("payment_transactions", {
|
|||||||
created_on: bigint("created_on", { mode: "number" }),
|
created_on: bigint("created_on", { mode: "number" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Property Expenses
|
||||||
|
export const property_expenses = pgTable("property_expenses", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
property_id: text("property_id").references(() => properties.id),
|
||||||
|
category: text("category"), // 'maintenance', 'repairs', 'utilities', 'insurance', 'taxes', 'supplies', 'services', 'other'
|
||||||
|
amount: doublePrecision("amount"),
|
||||||
|
description: text("description"),
|
||||||
|
expense_date: bigint("expense_date", { mode: "number" }),
|
||||||
|
payment_method: text("payment_method"), // 'card', 'ach', 'check', 'cash', 'transfer'
|
||||||
|
vendor: text("vendor"), // Vendor or service provider name
|
||||||
|
receipt_url: text("receipt_url"), // URL to receipt or invoice document
|
||||||
|
is_recurring: boolean("is_recurring").default(false),
|
||||||
|
recurring_frequency: text("recurring_frequency"), // 'monthly', 'quarterly', 'yearly' if recurring
|
||||||
|
created_on: bigint("created_on", { mode: "number" }),
|
||||||
|
updated_on: bigint("updated_on", { mode: "number" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rent Changes
|
||||||
|
export const rent_changes = pgTable("rent_changes", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
property_id: text("property_id").references(() => properties.id),
|
||||||
|
effective_date: bigint("effective_date", { mode: "number" }),
|
||||||
|
monthly_rent: doublePrecision("monthly_rent"),
|
||||||
|
reason: text("reason"), // Optional reason for the rent change
|
||||||
|
previous_rent: doublePrecision("previous_rent"), // Store the previous rent for reference
|
||||||
|
created_by: text("created_by").references(() => users.id), // User who made the change
|
||||||
|
created_on: bigint("created_on", { mode: "number" }),
|
||||||
|
});
|
||||||
|
|
||||||
// Reminders
|
// Reminders
|
||||||
export const reminders = pgTable("reminders", {
|
export const reminders = pgTable("reminders", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
@ -291,7 +320,9 @@ export const reminders = pgTable("reminders", {
|
|||||||
// Communication/Messages
|
// Communication/Messages
|
||||||
export const messages = pgTable("messages", {
|
export const messages = pgTable("messages", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
lease_id: text("lease_id").references(() => leases.id),
|
lease_id: text("lease_id").references(() => leases.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
sender_id: text("sender_id").references(() => users.id),
|
sender_id: text("sender_id").references(() => users.id),
|
||||||
recipient_id: text("recipient_id").references(() => users.id),
|
recipient_id: text("recipient_id").references(() => users.id),
|
||||||
message_type: text("message_type"), // 'general', 'maintenance', 'payment', 'notice'
|
message_type: text("message_type"), // 'general', 'maintenance', 'payment', 'notice'
|
||||||
|
|||||||
2
drizzle/0002_messages_lease_on_delete_set_null.sql
Normal file
2
drizzle/0002_messages_lease_on_delete_set_null.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "messages" DROP CONSTRAINT IF EXISTS "messages_lease_id_leases_id_fk";--> statement-breakpoint
|
||||||
|
ALTER TABLE "messages" ADD CONSTRAINT "messages_lease_id_leases_id_fk" FOREIGN KEY ("lease_id") REFERENCES "public"."leases"("id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||||
20
drizzle/0002_powerful_justin_hammer.sql
Normal file
20
drizzle/0002_powerful_justin_hammer.sql
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE "property_expenses" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"property_id" text,
|
||||||
|
"category" text,
|
||||||
|
"amount" double precision,
|
||||||
|
"description" text,
|
||||||
|
"expense_date" bigint,
|
||||||
|
"payment_method" text,
|
||||||
|
"vendor" text,
|
||||||
|
"receipt_url" text,
|
||||||
|
"is_recurring" boolean DEFAULT false,
|
||||||
|
"recurring_frequency" text,
|
||||||
|
"created_on" bigint,
|
||||||
|
"updated_on" bigint
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "messages" DROP CONSTRAINT "messages_lease_id_leases_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "property_expenses" ADD CONSTRAINT "property_expenses_property_id_properties_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."properties"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "messages" ADD CONSTRAINT "messages_lease_id_leases_id_fk" FOREIGN KEY ("lease_id") REFERENCES "public"."leases"("id") ON DELETE set null ON UPDATE no action;
|
||||||
13
drizzle/0003_strong_namora.sql
Normal file
13
drizzle/0003_strong_namora.sql
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE "rent_changes" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"property_id" text,
|
||||||
|
"effective_date" bigint,
|
||||||
|
"monthly_rent" double precision,
|
||||||
|
"reason" text,
|
||||||
|
"previous_rent" double precision,
|
||||||
|
"created_by" text,
|
||||||
|
"created_on" bigint
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "rent_changes" ADD CONSTRAINT "rent_changes_property_id_properties_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."properties"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "rent_changes" ADD CONSTRAINT "rent_changes_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
||||||
2272
drizzle/meta/0002_snapshot.json
Normal file
2272
drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2360
drizzle/meta/0003_snapshot.json
Normal file
2360
drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,20 @@
|
|||||||
"when": 1770386163089,
|
"when": 1770386163089,
|
||||||
"tag": "0001_wandering_wallflower",
|
"tag": "0001_wandering_wallflower",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1771310799998,
|
||||||
|
"tag": "0002_powerful_justin_hammer",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1771311449353,
|
||||||
|
"tag": "0003_strong_namora",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
26
helpers.ts
26
helpers.ts
@ -122,9 +122,6 @@ export async function decryptDataWithStoredPassword(
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const password = getEncryptionPassword(userId);
|
const password = getEncryptionPassword(userId);
|
||||||
if (!password) {
|
if (!password) {
|
||||||
console.log(
|
|
||||||
"[Encryption] decryptDataWithStoredPassword: no PIN for userId, skipping decrypt",
|
|
||||||
);
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@ -134,25 +131,15 @@ export async function decryptDataWithStoredPassword(
|
|||||||
parsed = JSON.parse(decoded) as unknown;
|
parsed = JSON.parse(decoded) as unknown;
|
||||||
} catch {
|
} catch {
|
||||||
// Not JSON: treat as legacy plain base64 payload
|
// Not JSON: treat as legacy plain base64 payload
|
||||||
console.log(
|
|
||||||
"[Encryption] decryptDataWithStoredPassword: legacy base64, returning decoded",
|
|
||||||
);
|
|
||||||
return decoded;
|
return decoded;
|
||||||
}
|
}
|
||||||
if (isEnvelope(parsed)) {
|
if (isEnvelope(parsed)) {
|
||||||
const out = await decryptEnvelope(password, parsed);
|
const out = await decryptEnvelope(password, parsed);
|
||||||
console.log(
|
|
||||||
"[Encryption] decryptDataWithStoredPassword: envelope decrypted ok",
|
|
||||||
);
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
// JSON but not an envelope: legacy
|
// JSON but not an envelope: legacy
|
||||||
console.log(
|
|
||||||
"[Encryption] decryptDataWithStoredPassword: JSON payload, returning as-is",
|
|
||||||
);
|
|
||||||
return decoded;
|
return decoded;
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.log("[Encryption] decryptDataWithStoredPassword: failed", e);
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -197,18 +184,12 @@ const ENCRYPTION_KEY_PREFIX = "encryption_password_";
|
|||||||
|
|
||||||
export function getEncryptionPassword(userId: string): string | null {
|
export function getEncryptionPassword(userId: string): string | null {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
console.log("[Encryption] getEncryptionPassword: no userId");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const pin = globalThis.localStorage?.getItem(
|
const pin = globalThis.localStorage?.getItem(
|
||||||
ENCRYPTION_KEY_PREFIX + userId,
|
ENCRYPTION_KEY_PREFIX + userId,
|
||||||
);
|
);
|
||||||
console.log(
|
|
||||||
"[Encryption] getEncryptionPassword:",
|
|
||||||
pin ? "PIN available" : "no PIN",
|
|
||||||
"key=" + ENCRYPTION_KEY_PREFIX + userId,
|
|
||||||
);
|
|
||||||
return pin;
|
return pin;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@ -221,16 +202,13 @@ export function setEncryptionPassword(
|
|||||||
password: string,
|
password: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
console.log("[Encryption] setEncryptionPassword: no userId, skip");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const key = getUserEncryptionKey(userId);
|
const key = getUserEncryptionKey(userId);
|
||||||
if (key) globalThis.localStorage?.setItem(key, password);
|
if (key) globalThis.localStorage?.setItem(key, password);
|
||||||
console.log("[Encryption] setEncryptionPassword: stored for key=" + key);
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.log("[Encryption] setEncryptionPassword: failed", e);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
89
islands/ConfirmSubmitGuard.tsx
Normal file
89
islands/ConfirmSubmitGuard.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { useEffect, useRef, useState } from "preact/hooks";
|
||||||
|
import { useFocusTrap } from "../utils/useFocusTrap.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens for submit on forms with data-confirm-message and shows a themed
|
||||||
|
* confirm dialog instead of the browser's native confirm().
|
||||||
|
* Add to any page and add data-confirm-message="..." (and optionally
|
||||||
|
* data-confirm-label="Confirm") to forms that should use the themed dialog.
|
||||||
|
*/
|
||||||
|
export default function ConfirmSubmitGuard() {
|
||||||
|
const [state, setState] = useState<
|
||||||
|
{
|
||||||
|
message: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
form: HTMLFormElement | null;
|
||||||
|
} | null
|
||||||
|
>(null);
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
useFocusTrap(state !== null, modalRef);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSubmit = (e: Event) => {
|
||||||
|
const form = e.target as HTMLFormElement;
|
||||||
|
const message = form?.getAttribute?.("data-confirm-message");
|
||||||
|
if (!message || !form) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const confirmLabel = form.getAttribute("data-confirm-label") || "Confirm";
|
||||||
|
setState({ message, confirmLabel, form });
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("submit", handleSubmit, true);
|
||||||
|
return () => document.removeEventListener("submit", handleSubmit, true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (state?.form) {
|
||||||
|
const form = state.form;
|
||||||
|
setState(null);
|
||||||
|
form.submit();
|
||||||
|
} else {
|
||||||
|
setState(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setState(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!state) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
class="theme-dialog-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="confirm-dialog-title"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="theme-dialog-container flex flex-col gap-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 id="confirm-dialog-title" class="theme-dialog-title">
|
||||||
|
Confirm
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm opacity-90" style={{ color: "var(--theme-muted)" }}>
|
||||||
|
{state.message}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-3 justify-end mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="theme-dialog-cancel-button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="theme-dialog-submit-button"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
{state.confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -200,19 +200,10 @@ export default function FinancialCalendar(
|
|||||||
let encryptionPassword = getEncryptionPassword(userId);
|
let encryptionPassword = getEncryptionPassword(userId);
|
||||||
const hasEncrypted = obligations.some((o) => o.encrypted_data) ||
|
const hasEncrypted = obligations.some((o) => o.encrypted_data) ||
|
||||||
incomes.some((i) => i.encrypted_data);
|
incomes.some((i) => i.encrypted_data);
|
||||||
console.log(
|
|
||||||
"[Calendar] processObligations: userId=" +
|
|
||||||
(userId ? "set" : "missing") + " pin=" +
|
|
||||||
(encryptionPassword ? "available" : "missing") + " hasEncrypted=" +
|
|
||||||
hasEncrypted,
|
|
||||||
);
|
|
||||||
|
|
||||||
// If no local PIN but we have encrypted data, try to fetch PIN from server (e.g. new device or calendar opened first)
|
// If no local PIN but we have encrypted data, try to fetch PIN from server (e.g. new device or calendar opened first)
|
||||||
if (!encryptionPassword && hasEncrypted) {
|
if (!encryptionPassword && hasEncrypted) {
|
||||||
try {
|
try {
|
||||||
console.log(
|
|
||||||
"[Calendar] processObligations: fetching PIN from /api/sync-pin/get",
|
|
||||||
);
|
|
||||||
const res = await fetch("/api/sync-pin/get", {
|
const res = await fetch("/api/sync-pin/get", {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
@ -221,32 +212,14 @@ export default function FinancialCalendar(
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.pin && setEncryptionPassword(userId, data.pin)) {
|
if (data.pin && setEncryptionPassword(userId, data.pin)) {
|
||||||
encryptionPassword = data.pin;
|
encryptionPassword = data.pin;
|
||||||
console.log(
|
|
||||||
"[Calendar] processObligations: PIN stored from server, using for decrypt",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
"[Calendar] processObligations: server response ok but no pin or set failed",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
"[Calendar] processObligations: sync-pin/get status=" +
|
|
||||||
res.status,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
console.log(
|
// sync-pin/get fetch failed
|
||||||
"[Calendar] processObligations: sync-pin/get fetch failed",
|
|
||||||
_e,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!encryptionPassword) {
|
if (!encryptionPassword) {
|
||||||
console.log(
|
|
||||||
"[Calendar] processObligations: no PIN, using obligations as-is",
|
|
||||||
);
|
|
||||||
dispatch({ type: "SET_PROCESSED_OBLIGATIONS", payload: obligations });
|
dispatch({ type: "SET_PROCESSED_OBLIGATIONS", payload: obligations });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -265,7 +238,6 @@ export default function FinancialCalendar(
|
|||||||
);
|
);
|
||||||
if (decryptedData) {
|
if (decryptedData) {
|
||||||
const parsed = JSON.parse(decryptedData);
|
const parsed = JSON.parse(decryptedData);
|
||||||
console.log("[Calendar] decrypted obligation JSON:", parsed);
|
|
||||||
if (parsed.amount !== undefined) {
|
if (parsed.amount !== undefined) {
|
||||||
processedObligation.amount = parsed.amount;
|
processedObligation.amount = parsed.amount;
|
||||||
}
|
}
|
||||||
@ -274,23 +246,14 @@ export default function FinancialCalendar(
|
|||||||
}
|
}
|
||||||
decryptedCount++;
|
decryptedCount++;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error(
|
// decrypt failed for this obligation
|
||||||
"[Calendar] Failed to decrypt obligation data:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedObligations.push(processedObligation);
|
updatedObligations.push(processedObligation);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decryptedCount > 0) {
|
|
||||||
console.log(
|
|
||||||
"[Calendar] processObligations: decrypted " + decryptedCount +
|
|
||||||
" obligation(s) with PIN",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_PROCESSED_OBLIGATIONS",
|
type: "SET_PROCESSED_OBLIGATIONS",
|
||||||
payload: updatedObligations,
|
payload: updatedObligations,
|
||||||
@ -310,17 +273,9 @@ export default function FinancialCalendar(
|
|||||||
let encryptionPassword = getEncryptionPassword(userId);
|
let encryptionPassword = getEncryptionPassword(userId);
|
||||||
const hasEncrypted = obligations.some((o) => o.encrypted_data) ||
|
const hasEncrypted = obligations.some((o) => o.encrypted_data) ||
|
||||||
incomes.some((i) => i.encrypted_data);
|
incomes.some((i) => i.encrypted_data);
|
||||||
console.log(
|
|
||||||
"[Calendar] processIncomes: userId=" + (userId ? "set" : "missing") +
|
|
||||||
" pin=" + (encryptionPassword ? "available" : "missing") +
|
|
||||||
" hasEncrypted=" + hasEncrypted,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!encryptionPassword && hasEncrypted) {
|
if (!encryptionPassword && hasEncrypted) {
|
||||||
try {
|
try {
|
||||||
console.log(
|
|
||||||
"[Calendar] processIncomes: fetching PIN from /api/sync-pin/get",
|
|
||||||
);
|
|
||||||
const res = await fetch("/api/sync-pin/get", {
|
const res = await fetch("/api/sync-pin/get", {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
@ -329,29 +284,14 @@ export default function FinancialCalendar(
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.pin && setEncryptionPassword(userId, data.pin)) {
|
if (data.pin && setEncryptionPassword(userId, data.pin)) {
|
||||||
encryptionPassword = data.pin;
|
encryptionPassword = data.pin;
|
||||||
console.log(
|
|
||||||
"[Calendar] processIncomes: PIN stored from server, using for decrypt",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
"[Calendar] processIncomes: server response ok but no pin or set failed",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
"[Calendar] processIncomes: sync-pin/get status=" + res.status,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
console.log(
|
// sync-pin/get fetch failed
|
||||||
"[Calendar] processIncomes: sync-pin/get fetch failed",
|
|
||||||
_e,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!encryptionPassword) {
|
if (!encryptionPassword) {
|
||||||
console.log("[Calendar] processIncomes: no PIN, using incomes as-is");
|
|
||||||
dispatch({ type: "SET_PROCESSED_INCOMES", payload: incomes });
|
dispatch({ type: "SET_PROCESSED_INCOMES", payload: incomes });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -370,7 +310,6 @@ export default function FinancialCalendar(
|
|||||||
);
|
);
|
||||||
if (decryptedData) {
|
if (decryptedData) {
|
||||||
const parsed = JSON.parse(decryptedData);
|
const parsed = JSON.parse(decryptedData);
|
||||||
console.log("[Calendar] decrypted income JSON:", parsed);
|
|
||||||
if (parsed.amount !== undefined) {
|
if (parsed.amount !== undefined) {
|
||||||
processedIncome.amount = parsed.amount;
|
processedIncome.amount = parsed.amount;
|
||||||
}
|
}
|
||||||
@ -379,20 +318,14 @@ export default function FinancialCalendar(
|
|||||||
}
|
}
|
||||||
decryptedCount++;
|
decryptedCount++;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("[Calendar] Failed to decrypt income data:", error);
|
// decrypt failed for this income
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedIncomes.push(processedIncome);
|
updatedIncomes.push(processedIncome);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decryptedCount > 0) {
|
|
||||||
console.log(
|
|
||||||
"[Calendar] processIncomes: decrypted " + decryptedCount +
|
|
||||||
" income(s) with PIN",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
dispatch({ type: "SET_PROCESSED_INCOMES", payload: updatedIncomes });
|
dispatch({ type: "SET_PROCESSED_INCOMES", payload: updatedIncomes });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -128,6 +128,124 @@ export default function IncomeList(
|
|||||||
processIncomes();
|
processIncomes();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
async function handleExportIncomes() {
|
||||||
|
const exportData = processedIncomes.map((inc) => ({
|
||||||
|
id: inc.id,
|
||||||
|
source: inc.source ?? "",
|
||||||
|
amount: inc.amount ?? 0,
|
||||||
|
frequency: inc.frequency ?? "",
|
||||||
|
received_date: inc.received_date
|
||||||
|
? new Date(inc.received_date).toISOString().split("T")[0]
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
const json = JSON.stringify(exportData, null, 2);
|
||||||
|
const blob = new Blob([json], { type: "application/json" });
|
||||||
|
const suggestedName = `incomes-${
|
||||||
|
new Date().toISOString().split("T")[0]
|
||||||
|
}.json`;
|
||||||
|
|
||||||
|
const showSave = (globalThis as { showSaveFilePicker?: unknown })
|
||||||
|
.showSaveFilePicker;
|
||||||
|
if (typeof showSave === "function") {
|
||||||
|
try {
|
||||||
|
const handle = await (showSave as (opts: {
|
||||||
|
suggestedName: string;
|
||||||
|
types: { description: string; accept: Record<string, string[]> }[];
|
||||||
|
}) => Promise<FileSystemFileHandle>)({
|
||||||
|
suggestedName,
|
||||||
|
types: [{
|
||||||
|
description: "JSON file",
|
||||||
|
accept: { "application/json": [".json"] },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
const writable = await handle.createWritable();
|
||||||
|
await writable.write(blob);
|
||||||
|
await writable.close();
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name !== "AbortError") {
|
||||||
|
console.error("Export failed:", err);
|
||||||
|
fallbackDownload(blob, suggestedName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fallbackDownload(blob, suggestedName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackDownload(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportIncomes() {
|
||||||
|
let file: File;
|
||||||
|
const showOpen = (globalThis as { showOpenFilePicker?: unknown })
|
||||||
|
.showOpenFilePicker;
|
||||||
|
if (typeof showOpen === "function") {
|
||||||
|
try {
|
||||||
|
const [handle] = await (showOpen as (opts: {
|
||||||
|
types: { description: string; accept: Record<string, string[]> }[];
|
||||||
|
multiple: boolean;
|
||||||
|
}) => Promise<FileSystemFileHandle[]>)({
|
||||||
|
types: [{
|
||||||
|
description: "JSON file",
|
||||||
|
accept: { "application/json": [".json"] },
|
||||||
|
}],
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
file = await handle.getFile();
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name !== "AbortError") {
|
||||||
|
console.error("Import file picker failed:", err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const chosen = await new Promise<File | null>((resolve) => {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = "application/json,.json";
|
||||||
|
input.onchange = () => resolve(input.files?.[0] ?? null);
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
if (!chosen) return;
|
||||||
|
file = chosen;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const data = JSON.parse(text) as unknown;
|
||||||
|
const list = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: (data as { incomes?: unknown[] })?.incomes;
|
||||||
|
if (!Array.isArray(list)) {
|
||||||
|
alert("Invalid file: expected a JSON array or { incomes: [] }");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch("/api/import/incomes", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(list),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
alert(err?.error ?? "Import failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await res.json() as { added: number; skipped: number };
|
||||||
|
if (result.added > 0 || result.skipped > 0) {
|
||||||
|
globalThis.location.reload();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Import failed:", e);
|
||||||
|
alert("Invalid JSON or import failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{popupAddIncomeDialog}
|
{popupAddIncomeDialog}
|
||||||
@ -136,6 +254,22 @@ export default function IncomeList(
|
|||||||
<h2 class="theme-text list-title">
|
<h2 class="theme-text list-title">
|
||||||
Your Incomes
|
Your Incomes
|
||||||
</h2>
|
</h2>
|
||||||
|
<div class="list-header-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="list-action-button list-action-button--small"
|
||||||
|
onClick={handleExportIncomes}
|
||||||
|
>
|
||||||
|
Export JSON
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="list-action-button list-action-button--small"
|
||||||
|
onClick={handleImportIncomes}
|
||||||
|
>
|
||||||
|
Import JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="list-action-button"
|
class="list-action-button"
|
||||||
|
|||||||
220
islands/LandlordPendingApprovals.tsx
Normal file
220
islands/LandlordPendingApprovals.tsx
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import { useState } from "preact/hooks";
|
||||||
|
import type { ConfirmAction } from "./LeaseApprovalConfirmDialog.tsx";
|
||||||
|
import LeaseApprovalConfirmDialog from "./LeaseApprovalConfirmDialog.tsx";
|
||||||
|
|
||||||
|
export interface PendingApprovalItem {
|
||||||
|
id: string;
|
||||||
|
property?: {
|
||||||
|
title?: string | null;
|
||||||
|
address_1?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
zip?: string | null;
|
||||||
|
monthly_rent?: number | null;
|
||||||
|
bedrooms?: number | null;
|
||||||
|
bathrooms?: number | null;
|
||||||
|
} | null;
|
||||||
|
property_address?: string | null;
|
||||||
|
tenant?: { name?: string | null; email?: string | null } | null;
|
||||||
|
approval_requested_at?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LandlordPendingApprovalsProps {
|
||||||
|
approvals: PendingApprovalItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddress(prop: PendingApprovalItem["property"]): string | null {
|
||||||
|
if (!prop) return null;
|
||||||
|
const parts = [
|
||||||
|
prop.address_1,
|
||||||
|
[prop.city, prop.state].filter(Boolean).join(", "),
|
||||||
|
prop.zip,
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.length > 0 ? parts.join(", ") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRentBeds(prop: PendingApprovalItem["property"]): string | null {
|
||||||
|
if (!prop) return null;
|
||||||
|
const hasRent = prop.monthly_rent != null;
|
||||||
|
const hasBeds = prop.bedrooms != null || prop.bathrooms != null;
|
||||||
|
if (!hasRent && !hasBeds) return null;
|
||||||
|
const rent = hasRent
|
||||||
|
? `$${Number(prop.monthly_rent).toLocaleString()}/mo`
|
||||||
|
: null;
|
||||||
|
const beds = hasBeds
|
||||||
|
? `${prop.bedrooms ?? "—"} bed, ${prop.bathrooms ?? "—"} bath`
|
||||||
|
: null;
|
||||||
|
return [rent, beds].filter(Boolean).join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LandlordPendingApprovals({
|
||||||
|
approvals,
|
||||||
|
}: LandlordPendingApprovalsProps) {
|
||||||
|
const [confirmState, setConfirmState] = useState<
|
||||||
|
{
|
||||||
|
action: ConfirmAction;
|
||||||
|
leaseId: string;
|
||||||
|
} | null
|
||||||
|
>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleApproveClick = (e: Event, leaseId: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setConfirmState({ action: "approve", leaseId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectClick = (e: Event, leaseId: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setConfirmState({ action: "reject", leaseId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!confirmState) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
const url = confirmState.action === "approve"
|
||||||
|
? "/api/lease/approve"
|
||||||
|
: "/api/lease/reject";
|
||||||
|
const body = confirmState.action === "reject"
|
||||||
|
? { lease_id: confirmState.leaseId, rejection_reason: "" }
|
||||||
|
: { lease_id: confirmState.leaseId };
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (res.redirected) {
|
||||||
|
window.location.href = res.url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
setConfirmState(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (!submitting) setConfirmState(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="dashboard-card dashboard-card--no-overflow">
|
||||||
|
<div class="dashboard-card-body">
|
||||||
|
<div class="dashboard-section-header">
|
||||||
|
<h3 class="dashboard-section-title">
|
||||||
|
Pending Lease Approvals
|
||||||
|
</h3>
|
||||||
|
<a href="/landlord/approvals" class="dashboard-section-link">
|
||||||
|
View all
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-list-spaced">
|
||||||
|
{approvals && approvals.length > 0
|
||||||
|
? (
|
||||||
|
approvals.slice(0, 3).map((approval) => {
|
||||||
|
const prop = approval.property;
|
||||||
|
const addressLine = formatAddress(prop);
|
||||||
|
const rentBeds = formatRentBeds(prop);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={approval.id}
|
||||||
|
class="dashboard-row dashboard-row--blue"
|
||||||
|
>
|
||||||
|
<div class="dashboard-approval-details">
|
||||||
|
<p class="dashboard-row-label">
|
||||||
|
{prop?.title || approval.property_address ||
|
||||||
|
"Property"}
|
||||||
|
</p>
|
||||||
|
{addressLine && (
|
||||||
|
<p class="dashboard-row-muted">{addressLine}</p>
|
||||||
|
)}
|
||||||
|
{rentBeds && (
|
||||||
|
<p class="dashboard-row-meta">{rentBeds}</p>
|
||||||
|
)}
|
||||||
|
<p class="dashboard-row-muted">
|
||||||
|
Tenant: {approval.tenant?.name ||
|
||||||
|
approval.tenant?.email || "Unknown"}
|
||||||
|
</p>
|
||||||
|
<p class="dashboard-row-meta">
|
||||||
|
Requested {approval.approval_requested_at
|
||||||
|
? new Date(
|
||||||
|
approval.approval_requested_at as number,
|
||||||
|
)
|
||||||
|
.toLocaleDateString()
|
||||||
|
: "Unknown date"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-actions-inline">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="/api/lease/approve"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleApproveClick(e, approval.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="lease_id"
|
||||||
|
value={approval.id}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="dashboard-badge dashboard-badge--green"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="/api/lease/reject"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleRejectClick(e, approval.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="lease_id"
|
||||||
|
value={approval.id}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="dashboard-badge dashboard-badge--red"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div class="dashboard-empty">
|
||||||
|
<p class="dashboard-empty-text">
|
||||||
|
No pending lease approvals
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LeaseApprovalConfirmDialog
|
||||||
|
open={confirmState !== null}
|
||||||
|
action={confirmState?.action ?? "approve"}
|
||||||
|
leaseId={confirmState?.leaseId ?? ""}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
islands/LeaseApprovalConfirmDialog.tsx
Normal file
75
islands/LeaseApprovalConfirmDialog.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { useRef } from "preact/hooks";
|
||||||
|
import { useFocusTrap } from "../utils/useFocusTrap.ts";
|
||||||
|
|
||||||
|
export type ConfirmAction = "approve" | "reject";
|
||||||
|
|
||||||
|
interface LeaseApprovalConfirmDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
action: ConfirmAction;
|
||||||
|
leaseId: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGES: Record<ConfirmAction, string> = {
|
||||||
|
approve:
|
||||||
|
"Are you sure you want to approve this lease? This will make the lease active and the tenant will be able to access the property.",
|
||||||
|
reject: "Are you sure you want to reject this lease request?",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONFIRM_LABELS: Record<ConfirmAction, string> = {
|
||||||
|
approve: "Approve",
|
||||||
|
reject: "Reject",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LeaseApprovalConfirmDialog({
|
||||||
|
open,
|
||||||
|
action,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: LeaseApprovalConfirmDialogProps) {
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
useFocusTrap(open, modalRef);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
class="theme-dialog-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="lease-confirm-title"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="theme-dialog-container flex flex-col gap-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 id="lease-confirm-title" class="theme-dialog-title">
|
||||||
|
{action === "approve" ? "Approve lease" : "Reject lease request"}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm opacity-90" style={{ color: "var(--theme-muted)" }}>
|
||||||
|
{MESSAGES[action]}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-3 justify-end mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="theme-dialog-cancel-button"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={action === "reject"
|
||||||
|
? "theme-dialog-submit-button theme-dialog-submit-button--danger"
|
||||||
|
: "theme-dialog-submit-button"}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{CONFIRM_LABELS[action]}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -25,16 +25,17 @@ export default function MemberHome(
|
|||||||
const loginPurpose = localStorage.getItem("login-purpose");
|
const loginPurpose = localStorage.getItem("login-purpose");
|
||||||
if (loginPurpose) {
|
if (loginPurpose) {
|
||||||
localStorage.removeItem("login-purpose");
|
localStorage.removeItem("login-purpose");
|
||||||
|
const tenantDone = Boolean(onboardingStatus?.onboarded_tenant);
|
||||||
const destination = loginPurpose === "landlord"
|
const destination = loginPurpose === "landlord"
|
||||||
? "/landlord/dashboard"
|
? "/landlord/dashboard"
|
||||||
: loginPurpose === "tenant"
|
: loginPurpose === "tenant"
|
||||||
? "/tenant/dashboard"
|
? (tenantDone ? "/tenant/dashboard" : "/tenant/onboarding")
|
||||||
: "";
|
: "";
|
||||||
if (destination) {
|
if (destination) {
|
||||||
globalThis.location.href = destination;
|
globalThis.location.href = destination;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, [onboardingStatus]);
|
||||||
|
|
||||||
const tileIds = [
|
const tileIds = [
|
||||||
{ id: "landlord-hub", label: "Landlord" },
|
{ id: "landlord-hub", label: "Landlord" },
|
||||||
@ -60,7 +61,7 @@ export default function MemberHome(
|
|||||||
|
|
||||||
const tenantPrimary = tenantOnboarded
|
const tenantPrimary = tenantOnboarded
|
||||||
? { label: "Open Tenant Dashboard", href: "/tenant/dashboard" }
|
? { label: "Open Tenant Dashboard", href: "/tenant/dashboard" }
|
||||||
: { label: "Continue Tenant Setup", href: "/tenant/dashboard" };
|
: { label: "Continue Tenant Setup", href: "/tenant/onboarding" };
|
||||||
|
|
||||||
const year = serverCalendarYear ?? new Date().getFullYear();
|
const year = serverCalendarYear ?? new Date().getFullYear();
|
||||||
const month = serverCalendarMonth ?? new Date().getMonth() + 1;
|
const month = serverCalendarMonth ?? new Date().getMonth() + 1;
|
||||||
|
|||||||
@ -182,9 +182,14 @@ export default function ObligationList(
|
|||||||
new Date().toISOString().split("T")[0]
|
new Date().toISOString().split("T")[0]
|
||||||
}.json`;
|
}.json`;
|
||||||
|
|
||||||
if (typeof globalThis.showSaveFilePicker === "function") {
|
const showSave = (globalThis as { showSaveFilePicker?: unknown })
|
||||||
|
.showSaveFilePicker;
|
||||||
|
if (typeof showSave === "function") {
|
||||||
try {
|
try {
|
||||||
const handle = await globalThis.showSaveFilePicker({
|
const handle = await (showSave as (opts: {
|
||||||
|
suggestedName: string;
|
||||||
|
types: { description: string; accept: Record<string, string[]> }[];
|
||||||
|
}) => Promise<FileSystemFileHandle>)({
|
||||||
suggestedName,
|
suggestedName,
|
||||||
types: [{
|
types: [{
|
||||||
description: "JSON file",
|
description: "JSON file",
|
||||||
@ -214,6 +219,71 @@ export default function ObligationList(
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleImportObligations() {
|
||||||
|
let file: File;
|
||||||
|
const showOpen = (globalThis as { showOpenFilePicker?: unknown })
|
||||||
|
.showOpenFilePicker;
|
||||||
|
if (typeof showOpen === "function") {
|
||||||
|
try {
|
||||||
|
const [handle] = await (showOpen as (opts: {
|
||||||
|
types: { description: string; accept: Record<string, string[]> }[];
|
||||||
|
multiple: boolean;
|
||||||
|
}) => Promise<FileSystemFileHandle[]>)({
|
||||||
|
types: [{
|
||||||
|
description: "JSON file",
|
||||||
|
accept: { "application/json": [".json"] },
|
||||||
|
}],
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
file = await handle.getFile();
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name !== "AbortError") {
|
||||||
|
console.error("Import file picker failed:", err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const chosen = await new Promise<File | null>((resolve) => {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = "application/json,.json";
|
||||||
|
input.onchange = () => resolve(input.files?.[0] ?? null);
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
if (!chosen) return;
|
||||||
|
file = chosen;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const data = JSON.parse(text) as unknown;
|
||||||
|
const list = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: (data as { obligations?: unknown[] })?.obligations;
|
||||||
|
if (!Array.isArray(list)) {
|
||||||
|
alert("Invalid file: expected a JSON array or { obligations: [] }");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch("/api/import/obligations", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(list),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
alert(err?.error ?? "Import failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await res.json() as { added: number; skipped: number };
|
||||||
|
if (result.added > 0 || result.skipped > 0) {
|
||||||
|
globalThis.location.reload();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Import failed:", e);
|
||||||
|
alert("Invalid JSON or import failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{popupAddObligationDialog}
|
{popupAddObligationDialog}
|
||||||
@ -222,13 +292,22 @@ export default function ObligationList(
|
|||||||
<h2 class="theme-text list-title">
|
<h2 class="theme-text list-title">
|
||||||
Your Obligations
|
Your Obligations
|
||||||
</h2>
|
</h2>
|
||||||
|
<div class="list-header-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="list-action-button list-action-button--center"
|
class="list-action-button list-action-button--small"
|
||||||
onClick={handleExportObligations}
|
onClick={handleExportObligations}
|
||||||
>
|
>
|
||||||
Export JSON
|
Export JSON
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="list-action-button list-action-button--small"
|
||||||
|
onClick={handleImportObligations}
|
||||||
|
>
|
||||||
|
Import JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="list-action-button"
|
class="list-action-button"
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "preact/hooks";
|
import { useEffect, useState } from "preact/hooks";
|
||||||
import { WelcomeBarProps, WelcomeBarState } from "./welcome-types.ts";
|
import { WelcomeBarProps, WelcomeBarState } from "./welcome-types.ts";
|
||||||
import { useEncryption } from "./useEncryption.ts";
|
import { useEncryption } from "./useEncryption.ts";
|
||||||
import { useLogoutCleanup, useServiceWorker } from "./useServiceWorker.ts";
|
import { useLogoutCleanup } from "./useServiceWorker.ts";
|
||||||
import EncryptionModal from "./EncryptionModal.tsx";
|
import EncryptionModal from "./EncryptionModal.tsx";
|
||||||
import NavigationMenu from "./NavigationMenu.tsx";
|
import NavigationMenu from "./NavigationMenu.tsx";
|
||||||
import LockIcon from "./LockIcon.tsx";
|
import LockIcon from "./LockIcon.tsx";
|
||||||
@ -46,7 +46,6 @@ export default function WelcomeBar(
|
|||||||
};
|
};
|
||||||
|
|
||||||
useLogoutCleanup();
|
useLogoutCleanup();
|
||||||
useServiceWorker();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncRoute = () => {
|
const syncRoute = () => {
|
||||||
|
|||||||
@ -148,9 +148,6 @@ export function useEncryption(
|
|||||||
// Check localStorage directly to see if password is already set
|
// Check localStorage directly to see if password is already set
|
||||||
const existingPassword = getEncryptionPassword(userId);
|
const existingPassword = getEncryptionPassword(userId);
|
||||||
if (existingPassword) {
|
if (existingPassword) {
|
||||||
console.log(
|
|
||||||
"[Encryption] useEncryption: local PIN already set for userId",
|
|
||||||
);
|
|
||||||
updateState({
|
updateState({
|
||||||
isPasswordSet: true,
|
isPasswordSet: true,
|
||||||
isSkipped: false,
|
isSkipped: false,
|
||||||
@ -182,14 +179,8 @@ export function useEncryption(
|
|||||||
if (me.ok) {
|
if (me.ok) {
|
||||||
const userData = await me.json() as User;
|
const userData = await me.json() as User;
|
||||||
if (userData.id && userData.encrypted_data) {
|
if (userData.id && userData.encrypted_data) {
|
||||||
console.log(
|
|
||||||
"[Encryption] useEncryption: user has encrypted_data, checking local pin",
|
|
||||||
);
|
|
||||||
const localPin = getEncryptionPassword(userId);
|
const localPin = getEncryptionPassword(userId);
|
||||||
if (localPin) {
|
if (localPin) {
|
||||||
console.log(
|
|
||||||
"[Encryption] useEncryption: local pin found, decrypting user data",
|
|
||||||
);
|
|
||||||
const decryptedData = await decryptDataWithStoredPassword(
|
const decryptedData = await decryptDataWithStoredPassword(
|
||||||
userId,
|
userId,
|
||||||
userData.encrypted_data,
|
userData.encrypted_data,
|
||||||
@ -197,9 +188,6 @@ export function useEncryption(
|
|||||||
if (decryptedData) {
|
if (decryptedData) {
|
||||||
const decryptedUserData = JSON.parse(decryptedData);
|
const decryptedUserData = JSON.parse(decryptedData);
|
||||||
if (decryptedUserData.id === userData.id) {
|
if (decryptedUserData.id === userData.id) {
|
||||||
console.log(
|
|
||||||
"[Encryption] useEncryption: local decrypt ok, user id match",
|
|
||||||
);
|
|
||||||
updateState({
|
updateState({
|
||||||
isPasswordSet: true,
|
isPasswordSet: true,
|
||||||
isSkipped: false,
|
isSkipped: false,
|
||||||
@ -207,15 +195,8 @@ export function useEncryption(
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
"[Encryption] useEncryption: local decrypt returned empty",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
|
||||||
"[Encryption] useEncryption: no local pin, fetching from server",
|
|
||||||
);
|
|
||||||
const response = await fetch("/api/sync-pin/get", {
|
const response = await fetch("/api/sync-pin/get", {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
@ -227,10 +208,6 @@ export function useEncryption(
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const serverPinData = await response.json();
|
const serverPinData = await response.json();
|
||||||
console.log(
|
|
||||||
"[Encryption] useEncryption: server pin response ok",
|
|
||||||
serverPinData.pin ? "pin present" : "no pin",
|
|
||||||
);
|
|
||||||
// Server has a pin, store it in localStorage for local usage
|
// Server has a pin, store it in localStorage for local usage
|
||||||
if (serverPinData.pin) {
|
if (serverPinData.pin) {
|
||||||
const decryptedData = await tryDecryptDataWithPin(
|
const decryptedData = await tryDecryptDataWithPin(
|
||||||
@ -240,9 +217,6 @@ export function useEncryption(
|
|||||||
if (decryptedData) {
|
if (decryptedData) {
|
||||||
const decryptedUserData = JSON.parse(decryptedData);
|
const decryptedUserData = JSON.parse(decryptedData);
|
||||||
if (decryptedUserData.id === userData.id) {
|
if (decryptedUserData.id === userData.id) {
|
||||||
console.log(
|
|
||||||
"[Encryption] useEncryption: server pin decrypt ok, storing locally",
|
|
||||||
);
|
|
||||||
const localSetSuccess = setEncryptionPassword(
|
const localSetSuccess = setEncryptionPassword(
|
||||||
userId,
|
userId,
|
||||||
serverPinData.pin,
|
serverPinData.pin,
|
||||||
@ -258,10 +232,6 @@ export function useEncryption(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
"[Encryption] useEncryption: server pin decrypt failed or no user id match",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server pin was not usable for this encrypted data, fall back to asking user
|
// Server pin was not usable for this encrypted data, fall back to asking user
|
||||||
|
|||||||
@ -31,44 +31,6 @@ function safeSessionStorage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensures no Service Worker is used (temporary).
|
|
||||||
* Unregisters all service workers and verifies none remain.
|
|
||||||
*/
|
|
||||||
export function useServiceWorker() {
|
|
||||||
useEffect(() => {
|
|
||||||
if (!("serviceWorker" in navigator)) return;
|
|
||||||
|
|
||||||
navigator.serviceWorker.getRegistrations()
|
|
||||||
.then((registrations) => {
|
|
||||||
if (registrations.length === 0) {
|
|
||||||
console.log("[SW] No service workers registered (OK).");
|
|
||||||
return [] as ServiceWorkerRegistration[];
|
|
||||||
}
|
|
||||||
const unregisterPromises = registrations.map((reg) =>
|
|
||||||
reg.unregister().then(() => {
|
|
||||||
console.log("[SW] Unregistered:", reg.scope);
|
|
||||||
}).catch((err) => {
|
|
||||||
console.warn("[SW] Unregister failed for", reg.scope, err);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return Promise.all(unregisterPromises).then(() =>
|
|
||||||
navigator.serviceWorker.getRegistrations()
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.then((after) => {
|
|
||||||
if (after && after.length > 0) {
|
|
||||||
console.warn("[SW] Still registered after unregister:", after.length);
|
|
||||||
} else {
|
|
||||||
console.log("[SW] Verified: no service workers registered.");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.warn("[SW] Check failed:", err);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook to manage logout button state
|
* Custom hook to manage logout button state
|
||||||
* Handles post-logout redirect cleanup
|
* Handles post-logout redirect cleanup
|
||||||
|
|||||||
130
routes/api/import/incomes.ts
Normal file
130
routes/api/import/incomes.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
// routes/api/import/incomes.ts
|
||||||
|
// Import incomes from JSON; skip items that already exist (by id or by content).
|
||||||
|
|
||||||
|
import { define } from "../../../utils.ts";
|
||||||
|
import {
|
||||||
|
createIncome,
|
||||||
|
getIncomesByUser,
|
||||||
|
} from "../../../services/financhalServices.ts";
|
||||||
|
import { SessionCookie } from "../../auth/callback.ts";
|
||||||
|
|
||||||
|
type ImportItem = {
|
||||||
|
id?: string;
|
||||||
|
source?: string | null;
|
||||||
|
amount?: number | null;
|
||||||
|
frequency?: string | null;
|
||||||
|
received_date?: string | number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function signature(inc: {
|
||||||
|
source?: string | null;
|
||||||
|
amount?: number | null;
|
||||||
|
frequency?: string | null;
|
||||||
|
received_date?: number | null;
|
||||||
|
}): string {
|
||||||
|
const r = inc.received_date ?? 0;
|
||||||
|
return `${inc.source ?? ""}|${inc.amount ?? 0}|${inc.frequency ?? ""}|${r}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler = define.handlers({
|
||||||
|
async POST(ctx) {
|
||||||
|
try {
|
||||||
|
const stateData: SessionCookie = ctx.state.data;
|
||||||
|
const user_id = stateData?.user_id;
|
||||||
|
if (!user_id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Unauthorized" }),
|
||||||
|
{ status: 401, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = ctx.req.headers.get("content-type") ?? "";
|
||||||
|
if (!contentType.includes("application/json")) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Content-Type must be application/json" }),
|
||||||
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await ctx.req.json();
|
||||||
|
const raw = Array.isArray(body) ? body : body?.incomes;
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Body must be an array or { incomes: [] }",
|
||||||
|
}),
|
||||||
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await getIncomesByUser(user_id);
|
||||||
|
const existingIds = new Set(existing.map((i) => i.id));
|
||||||
|
const existingSigs = new Set(
|
||||||
|
existing.map((i) =>
|
||||||
|
signature({
|
||||||
|
source: i.source,
|
||||||
|
amount: i.amount,
|
||||||
|
frequency: i.frequency,
|
||||||
|
received_date: i.received_date,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let added = 0;
|
||||||
|
for (const item of raw as ImportItem[]) {
|
||||||
|
const id = typeof item.id === "string" ? item.id : undefined;
|
||||||
|
if (id && existingIds.has(id)) continue;
|
||||||
|
|
||||||
|
const amount = item.amount != null ? Number(item.amount) : null;
|
||||||
|
const receivedRaw = item.received_date;
|
||||||
|
let received_date: number;
|
||||||
|
if (receivedRaw == null || receivedRaw === "") {
|
||||||
|
received_date = Date.now();
|
||||||
|
} else if (typeof receivedRaw === "number") {
|
||||||
|
received_date = receivedRaw;
|
||||||
|
} else {
|
||||||
|
received_date =
|
||||||
|
Date.parse(String(receivedRaw).replace(/Z?$/, "T00:00:00")) ||
|
||||||
|
Date.now();
|
||||||
|
}
|
||||||
|
const src = item.source != null ? String(item.source) : null;
|
||||||
|
const freq = (item.frequency != null ? String(item.frequency) : "") ||
|
||||||
|
"monthly";
|
||||||
|
|
||||||
|
const sig = signature({
|
||||||
|
source: src,
|
||||||
|
amount,
|
||||||
|
frequency: freq,
|
||||||
|
received_date,
|
||||||
|
});
|
||||||
|
if (existingSigs.has(sig)) continue;
|
||||||
|
|
||||||
|
await createIncome(
|
||||||
|
user_id,
|
||||||
|
src,
|
||||||
|
amount != null && amount > 0 ? amount : null,
|
||||||
|
freq,
|
||||||
|
received_date,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
added++;
|
||||||
|
existingSigs.add(sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
added,
|
||||||
|
total: raw.length,
|
||||||
|
skipped: raw.length - added,
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Import incomes error:", e);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Failed to import incomes" }),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
130
routes/api/import/obligations.ts
Normal file
130
routes/api/import/obligations.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
// routes/api/import/obligations.ts
|
||||||
|
// Import obligations from JSON; skip items that already exist (by id or by content).
|
||||||
|
|
||||||
|
import { define } from "../../../utils.ts";
|
||||||
|
import {
|
||||||
|
createObligation,
|
||||||
|
getObligationsByUser,
|
||||||
|
} from "../../../services/financhalServices.ts";
|
||||||
|
import { SessionCookie } from "../../auth/callback.ts";
|
||||||
|
|
||||||
|
type ImportItem = {
|
||||||
|
id?: string;
|
||||||
|
description?: string | null;
|
||||||
|
amount?: number | null;
|
||||||
|
frequency?: string | null;
|
||||||
|
due_date?: string | number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function signature(ob: {
|
||||||
|
description?: string | null;
|
||||||
|
amount?: number | null;
|
||||||
|
frequency?: string | null;
|
||||||
|
due_date?: number | null;
|
||||||
|
}): string {
|
||||||
|
const d = ob.due_date ?? 0;
|
||||||
|
return `${ob.description ?? ""}|${ob.amount ?? 0}|${ob.frequency ?? ""}|${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler = define.handlers({
|
||||||
|
async POST(ctx) {
|
||||||
|
try {
|
||||||
|
const stateData: SessionCookie = ctx.state.data;
|
||||||
|
const user_id = stateData?.user_id;
|
||||||
|
if (!user_id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Unauthorized" }),
|
||||||
|
{ status: 401, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = ctx.req.headers.get("content-type") ?? "";
|
||||||
|
if (!contentType.includes("application/json")) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Content-Type must be application/json" }),
|
||||||
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await ctx.req.json();
|
||||||
|
const raw = Array.isArray(body) ? body : body?.obligations;
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Body must be an array or { obligations: [] }",
|
||||||
|
}),
|
||||||
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await getObligationsByUser(user_id);
|
||||||
|
const existingIds = new Set(existing.map((o) => o.id));
|
||||||
|
const existingSigs = new Set(
|
||||||
|
existing.map((o) =>
|
||||||
|
signature({
|
||||||
|
description: o.description,
|
||||||
|
amount: o.amount,
|
||||||
|
frequency: o.frequency,
|
||||||
|
due_date: o.due_date,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let added = 0;
|
||||||
|
for (const item of raw as ImportItem[]) {
|
||||||
|
const id = typeof item.id === "string" ? item.id : undefined;
|
||||||
|
if (id && existingIds.has(id)) continue;
|
||||||
|
|
||||||
|
const amount = item.amount != null ? Number(item.amount) : null;
|
||||||
|
const dueDateRaw = item.due_date;
|
||||||
|
let due_date: number;
|
||||||
|
if (dueDateRaw == null || dueDateRaw === "") {
|
||||||
|
due_date = 0;
|
||||||
|
} else if (typeof dueDateRaw === "number") {
|
||||||
|
due_date = dueDateRaw;
|
||||||
|
} else {
|
||||||
|
due_date =
|
||||||
|
Date.parse(String(dueDateRaw).replace(/Z?$/, "T00:00:00")) ||
|
||||||
|
0;
|
||||||
|
}
|
||||||
|
const desc = item.description != null ? String(item.description) : null;
|
||||||
|
const freq = (item.frequency != null ? String(item.frequency) : "") ||
|
||||||
|
"monthly";
|
||||||
|
|
||||||
|
const sig = signature({
|
||||||
|
description: desc,
|
||||||
|
amount,
|
||||||
|
frequency: freq,
|
||||||
|
due_date: due_date || undefined,
|
||||||
|
});
|
||||||
|
if (existingSigs.has(sig)) continue;
|
||||||
|
|
||||||
|
await createObligation(
|
||||||
|
user_id,
|
||||||
|
desc,
|
||||||
|
amount != null && amount > 0 ? amount : null,
|
||||||
|
freq,
|
||||||
|
due_date,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
added++;
|
||||||
|
existingSigs.add(sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
added,
|
||||||
|
total: raw.length,
|
||||||
|
skipped: raw.length - added,
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Import obligations error:", e);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Failed to import obligations" }),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -58,17 +58,23 @@ export const handler = define.handlers({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lease.owner_id !== user_id) {
|
// Check if user owns the property (pending leases have owner_id=null)
|
||||||
|
if (lease.property_id) {
|
||||||
|
const property = await getPropertyById(lease.property_id);
|
||||||
|
if (!property || property.owner_id !== user_id) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "Unauthorized: You do not own this lease" }),
|
JSON.stringify({
|
||||||
|
error: "Unauthorized: You do not own this property",
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 403,
|
status: 403,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await approveLease(lease_id, user_id);
|
const result = await approveLease(lease_id, user_id, lease.property_id);
|
||||||
|
|
||||||
if (!result || result.length === 0) {
|
if (!result || result.length === 0) {
|
||||||
return new Response(
|
return new Response(
|
||||||
|
|||||||
@ -60,17 +60,28 @@ export const handler = define.handlers({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lease.owner_id !== user_id) {
|
// Check if user owns the property (pending leases have owner_id=null)
|
||||||
|
if (lease.property_id) {
|
||||||
|
const property = await getPropertyById(lease.property_id);
|
||||||
|
if (!property || property.owner_id !== user_id) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "Unauthorized: You do not own this lease" }),
|
JSON.stringify({
|
||||||
|
error: "Unauthorized: You do not own this property",
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 403,
|
status: 403,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await rejectLease(lease_id, user_id, rejection_reason);
|
const result = await rejectLease(
|
||||||
|
lease_id,
|
||||||
|
user_id,
|
||||||
|
rejection_reason,
|
||||||
|
lease.property_id,
|
||||||
|
);
|
||||||
|
|
||||||
if (!result || result.length === 0) {
|
if (!result || result.length === 0) {
|
||||||
return new Response(
|
return new Response(
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
} from "../../../services/tenantInviteService.ts";
|
} from "../../../services/tenantInviteService.ts";
|
||||||
import { getPropertyById } from "../../../services/propertyService.ts";
|
import { getPropertyById } from "../../../services/propertyService.ts";
|
||||||
import { getUserById } from "../../../services/userService.ts";
|
import { getUserById } from "../../../services/userService.ts";
|
||||||
|
import { createMessage } from "../../../services/messageService.ts";
|
||||||
import {
|
import {
|
||||||
sendLeaseApprovalRequest,
|
sendLeaseApprovalRequest,
|
||||||
} from "../../../services/notificationService.ts";
|
} from "../../../services/notificationService.ts";
|
||||||
@ -102,13 +103,26 @@ export const handler = define.handlers({
|
|||||||
if (invite.landlord_id) {
|
if (invite.landlord_id) {
|
||||||
const tenant = await getUserById(user_id);
|
const tenant = await getUserById(user_id);
|
||||||
const landlord = await getUserById(invite.landlord_id);
|
const landlord = await getUserById(invite.landlord_id);
|
||||||
|
const propertyAddress = property.address_1
|
||||||
|
? `${property.address_1}, ${property.city}, ${property.state}`
|
||||||
|
: property.title || "Property";
|
||||||
|
const tenantName = tenant?.name || "Tenant";
|
||||||
|
|
||||||
|
await createMessage(
|
||||||
|
lease.id,
|
||||||
|
user_id,
|
||||||
|
invite.landlord_id,
|
||||||
|
"notice",
|
||||||
|
"Tenant requesting lease approval",
|
||||||
|
`${tenantName} is requesting a lease application for ${propertyAddress}. Please review the request and send the lease documents when ready.`,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
await sendLeaseApprovalRequest(invite.landlord_id, {
|
await sendLeaseApprovalRequest(invite.landlord_id, {
|
||||||
lease_id: lease.id,
|
lease_id: lease.id,
|
||||||
property_address: property.address_1
|
property_address: propertyAddress,
|
||||||
? `${property.address_1}, ${property.city}, ${property.state}`
|
tenant_name: tenantName,
|
||||||
: property.title || "Property",
|
|
||||||
tenant_name: tenant?.name || "Tenant",
|
|
||||||
landlord_name: landlord?.name || "Landlord",
|
landlord_name: landlord?.name || "Landlord",
|
||||||
monthly_rent: property.monthly_rent ?? undefined,
|
monthly_rent: property.monthly_rent ?? undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -141,6 +141,7 @@ export const handler = define.handlers({
|
|||||||
if (!invite && propertyId) {
|
if (!invite && propertyId) {
|
||||||
const created = await createTenantInvite(user_id, propertyId, {
|
const created = await createTenantInvite(user_id, propertyId, {
|
||||||
availableOn,
|
availableOn,
|
||||||
|
invitedEmails: invitedEmails.length > 0 ? invitedEmails : undefined,
|
||||||
});
|
});
|
||||||
invite = created[0] || null;
|
invite = created[0] || null;
|
||||||
} else if (!invite && !propertyId) {
|
} else if (!invite && !propertyId) {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { define } from "../../../utils.ts";
|
|||||||
import { page } from "fresh";
|
import { page } from "fresh";
|
||||||
import { SessionCookie } from "../../auth/callback.ts";
|
import { SessionCookie } from "../../auth/callback.ts";
|
||||||
import { createPublicId } from "../../../helpers.ts";
|
import { createPublicId } from "../../../helpers.ts";
|
||||||
|
import ConfirmSubmitGuard from "../../../islands/ConfirmSubmitGuard.tsx";
|
||||||
|
|
||||||
interface PendingApproval {
|
interface PendingApproval {
|
||||||
id: string;
|
id: string;
|
||||||
@ -151,6 +152,8 @@ export default define.page(
|
|||||||
const { approvals, error } = data;
|
const { approvals, error } = data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<ConfirmSubmitGuard />
|
||||||
<div class="min-h-screen bg-gray-50 landlord-route">
|
<div class="min-h-screen bg-gray-50 landlord-route">
|
||||||
<div class="bg-white shadow">
|
<div class="bg-white shadow">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
@ -240,7 +243,8 @@ export default define.page(
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex items-center text-sm text-gray-500 sm:mt-0">
|
<div class="mt-2 flex items-center text-sm text-gray-500 sm:mt-0">
|
||||||
<p>
|
<p>
|
||||||
Monthly Rent: ${approval.property?.monthly_rent
|
Monthly Rent:
|
||||||
|
${approval.property?.monthly_rent
|
||||||
? `$${approval.property.monthly_rent.toLocaleString()}/mo`
|
? `$${approval.property.monthly_rent.toLocaleString()}/mo`
|
||||||
: "N/A"}
|
: "N/A"}
|
||||||
</p>
|
</p>
|
||||||
@ -259,7 +263,12 @@ export default define.page(
|
|||||||
{approval.approval_status === "pending" && (
|
{approval.approval_status === "pending" && (
|
||||||
<div class="mt-4 space-y-3">
|
<div class="mt-4 space-y-3">
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<form method="POST" class="inline">
|
<form
|
||||||
|
method="POST"
|
||||||
|
class="inline"
|
||||||
|
data-confirm-message="Are you sure you want to approve this lease? This will make the lease active and the tenant will be able to access the property."
|
||||||
|
data-confirm-label="Approve"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name="lease_id"
|
name="lease_id"
|
||||||
@ -277,7 +286,12 @@ export default define.page(
|
|||||||
Approve
|
Approve
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" class="inline">
|
<form
|
||||||
|
method="POST"
|
||||||
|
class="inline"
|
||||||
|
data-confirm-message="Are you sure you want to reject this lease request?"
|
||||||
|
data-confirm-label="Reject"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name="lease_id"
|
name="lease_id"
|
||||||
@ -297,7 +311,12 @@ export default define.page(
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" class="mt-2">
|
<form
|
||||||
|
method="POST"
|
||||||
|
class="mt-2"
|
||||||
|
data-confirm-message="Are you sure you want to reject this lease request?"
|
||||||
|
data-confirm-label="Reject with Reason"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name="lease_id"
|
name="lease_id"
|
||||||
@ -396,6 +415,7 @@ export default define.page(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,13 +4,14 @@ import {
|
|||||||
getLandlordDashboardData,
|
getLandlordDashboardData,
|
||||||
getPendingLeaseApprovals,
|
getPendingLeaseApprovals,
|
||||||
} from "../../../services/landlordService.ts";
|
} from "../../../services/landlordService.ts";
|
||||||
import { DashboardTenantApplication } from "../../../services/tenantApplicationService.ts";
|
import { getUnreadCount } from "../../../services/messageService.ts";
|
||||||
import { DashboardMaintenanceRequest } from "../../../services/maintenanceRequestService.ts";
|
import { DashboardMaintenanceRequest } from "../../../services/maintenanceRequestService.ts";
|
||||||
import { DashboardMessage } from "../../../services/messageService.ts";
|
import { DashboardMessage } from "../../../services/messageService.ts";
|
||||||
import { page } from "fresh";
|
import { page } from "fresh";
|
||||||
import { SessionCookie } from "../../auth/callback.ts";
|
import { SessionCookie } from "../../auth/callback.ts";
|
||||||
import { MakeNewDate } from "../../../helpers.ts";
|
import { MakeNewDate } from "../../../helpers.ts";
|
||||||
import { Lease, Property, User } from "../../../db/interfaces.ts";
|
import { Lease, Property, User } from "../../../db/interfaces.ts";
|
||||||
|
import LandlordPendingApprovals from "../../../islands/LandlordPendingApprovals.tsx";
|
||||||
|
|
||||||
// Interface for pending lease approval with nested property and tenant data
|
// Interface for pending lease approval with nested property and tenant data
|
||||||
interface PendingLeaseApproval extends Lease {
|
interface PendingLeaseApproval extends Lease {
|
||||||
@ -51,11 +52,11 @@ interface FlexibleDashboardData {
|
|||||||
overdue: number;
|
overdue: number;
|
||||||
}[];
|
}[];
|
||||||
// Additional UI properties with proper types from service layer
|
// Additional UI properties with proper types from service layer
|
||||||
tenantApplications?: DashboardTenantApplication[];
|
|
||||||
maintenanceRequests?: DashboardMaintenanceRequest[];
|
maintenanceRequests?: DashboardMaintenanceRequest[];
|
||||||
recentMessages?: DashboardMessage[];
|
recentMessages?: DashboardMessage[];
|
||||||
monthlyRevenue?: number;
|
monthlyRevenue?: number;
|
||||||
pendingLeaseApprovals?: PendingLeaseApproval[];
|
pendingLeaseApprovals?: PendingLeaseApproval[];
|
||||||
|
unreadMessageCount?: number;
|
||||||
[key: string]: unknown; // Allow any additional properties
|
[key: string]: unknown; // Allow any additional properties
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,12 +80,14 @@ export const handler = define.handlers({
|
|||||||
try {
|
try {
|
||||||
const dashboardData = await getLandlordDashboardData(user_id);
|
const dashboardData = await getLandlordDashboardData(user_id);
|
||||||
const pendingApprovals = await getPendingLeaseApprovals(user_id);
|
const pendingApprovals = await getPendingLeaseApprovals(user_id);
|
||||||
|
const unreadMessageCount = await getUnreadCount(user_id);
|
||||||
|
|
||||||
return page({
|
return page({
|
||||||
stateData,
|
stateData,
|
||||||
dashboardData: {
|
dashboardData: {
|
||||||
...dashboardData,
|
...dashboardData,
|
||||||
pendingApprovals,
|
pendingLeaseApprovals: pendingApprovals,
|
||||||
|
unreadMessageCount,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -103,16 +106,11 @@ export default define.page(
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div class="min-h-screen bg-gray-50 flex items-center justify-center landlord-route">
|
<div class="dashboard-error-page landlord-route">
|
||||||
<div class="text-center">
|
<div class="dashboard-error-inner">
|
||||||
<h1 class="text-2xl font-bold text-gray-900 mb-4">
|
<h1 class="dashboard-error-title">Dashboard Error</h1>
|
||||||
Dashboard Error
|
<p class="dashboard-error-text">{error}</p>
|
||||||
</h1>
|
<a href="/onboarding#landlord" class="dashboard-error-link">
|
||||||
<p class="text-gray-600">{error}</p>
|
|
||||||
<a
|
|
||||||
href="/onboarding#landlord"
|
|
||||||
class="mt-4 inline-block text-blue-600 hover:text-blue-500"
|
|
||||||
>
|
|
||||||
Return to Onboarding
|
Return to Onboarding
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -122,54 +120,62 @@ export default define.page(
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
properties,
|
properties,
|
||||||
tenantApplications,
|
|
||||||
maintenanceRequests,
|
maintenanceRequests,
|
||||||
recentMessages,
|
recentMessages,
|
||||||
monthlyRevenue,
|
monthlyRevenue,
|
||||||
pendingLeaseApprovals,
|
pendingLeaseApprovals,
|
||||||
|
unreadMessageCount = 0,
|
||||||
} = dashboardData || {};
|
} = dashboardData || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="min-h-screen bg-gray-50 landlord-route">
|
<div class="dashboard-page landlord-route">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div class="bg-white shadow">
|
<div class="dashboard-header">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="dashboard-header-inner">
|
||||||
<div class="flex justify-between items-center py-6">
|
<div class="dashboard-header-row">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900">
|
<h1 class="dashboard-header-title">Landlord Dashboard</h1>
|
||||||
Landlord Dashboard
|
<p class="dashboard-header-subtitle">
|
||||||
</h1>
|
|
||||||
<p class="mt-1 text-sm text-gray-500">
|
|
||||||
Welcome back! Here's what's happening with your properties.
|
Welcome back! Here's what's happening with your properties.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-3">
|
<div class="dashboard-header-actions">
|
||||||
<a
|
<a
|
||||||
href="/landlord/properties/add"
|
href="/landlord/properties/add"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
class="dashboard-btn dashboard-btn--primary"
|
||||||
>
|
>
|
||||||
Add Property
|
Add Property
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/landlord/tenants/invite"
|
href="/landlord/tenants/invite"
|
||||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
class="dashboard-btn dashboard-btn--secondary"
|
||||||
>
|
>
|
||||||
Invite Tenant
|
Invite Tenant
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="/landlord/messages"
|
||||||
|
class={`dashboard-btn ${
|
||||||
|
unreadMessageCount > 0
|
||||||
|
? "dashboard-btn--messages-new"
|
||||||
|
: "dashboard-btn--secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Messages
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="dashboard-content">
|
||||||
{/* Stats Overview */}
|
{/* Stats Overview */}
|
||||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
<div class="dashboard-stats-grid">
|
||||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
<div class="dashboard-card">
|
||||||
<div class="p-5">
|
<div class="dashboard-card-body">
|
||||||
<div class="flex items-center">
|
<div class="dashboard-stat-card">
|
||||||
<div class="flex-shrink-0">
|
<div class="dashboard-stat-icon-wrap">
|
||||||
<svg
|
<svg
|
||||||
class="h-6 w-6 text-gray-400"
|
class="dashboard-stat-icon"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -182,12 +188,10 @@ export default define.page(
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="dashboard-stat-content">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">
|
<dt class="dashboard-stat-label">Total Properties</dt>
|
||||||
Total Properties
|
<dd class="dashboard-stat-value">
|
||||||
</dt>
|
|
||||||
<dd class="text-lg font-medium text-gray-900">
|
|
||||||
{properties?.length || 0}
|
{properties?.length || 0}
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
@ -196,12 +200,12 @@ export default define.page(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
<div class="dashboard-card">
|
||||||
<div class="p-5">
|
<div class="dashboard-card-body">
|
||||||
<div class="flex items-center">
|
<div class="dashboard-stat-card">
|
||||||
<div class="flex-shrink-0">
|
<div class="dashboard-stat-icon-wrap">
|
||||||
<svg
|
<svg
|
||||||
class="h-6 w-6 text-gray-400"
|
class="dashboard-stat-icon"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -210,19 +214,15 @@ export default define.page(
|
|||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="dashboard-stat-content">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">
|
<dt class="dashboard-stat-label">Pending Approvals</dt>
|
||||||
Pending Applications
|
<dd class="dashboard-stat-value">
|
||||||
</dt>
|
{pendingLeaseApprovals?.length || 0}
|
||||||
<dd class="text-lg font-medium text-gray-900">
|
|
||||||
{tenantApplications?.filter((app) =>
|
|
||||||
app.status === "pending"
|
|
||||||
).length || 0}
|
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
@ -230,12 +230,12 @@ export default define.page(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
<div class="dashboard-card">
|
||||||
<div class="p-5">
|
<div class="dashboard-card-body">
|
||||||
<div class="flex items-center">
|
<div class="dashboard-stat-card">
|
||||||
<div class="flex-shrink-0">
|
<div class="dashboard-stat-icon-wrap">
|
||||||
<svg
|
<svg
|
||||||
class="h-6 w-6 text-gray-400"
|
class="dashboard-stat-icon"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -248,12 +248,10 @@ export default define.page(
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="dashboard-stat-content">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">
|
<dt class="dashboard-stat-label">Open Maintenance</dt>
|
||||||
Open Maintenance
|
<dd class="dashboard-stat-value">
|
||||||
</dt>
|
|
||||||
<dd class="text-lg font-medium text-gray-900">
|
|
||||||
{maintenanceRequests?.filter((req) =>
|
{maintenanceRequests?.filter((req) =>
|
||||||
req.status === "open"
|
req.status === "open"
|
||||||
).length || 0}
|
).length || 0}
|
||||||
@ -264,12 +262,12 @@ export default define.page(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
<div class="dashboard-card">
|
||||||
<div class="p-5">
|
<div class="dashboard-card-body">
|
||||||
<div class="flex items-center">
|
<div class="dashboard-stat-card">
|
||||||
<div class="flex-shrink-0">
|
<div class="dashboard-stat-icon-wrap">
|
||||||
<svg
|
<svg
|
||||||
class="h-6 w-6 text-gray-400"
|
class="dashboard-stat-icon"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -282,12 +280,10 @@ export default define.page(
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="dashboard-stat-content">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">
|
<dt class="dashboard-stat-label">Monthly Revenue</dt>
|
||||||
Monthly Revenue
|
<dd class="dashboard-stat-value">
|
||||||
</dt>
|
|
||||||
<dd class="text-lg font-medium text-gray-900">
|
|
||||||
${monthlyRevenue?.toLocaleString() || "0"}
|
${monthlyRevenue?.toLocaleString() || "0"}
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
@ -297,57 +293,55 @@ export default define.page(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
<div class="dashboard-two-col">
|
||||||
{/* Recent Properties */}
|
{/* Recent Properties */}
|
||||||
<div class="bg-white shadow rounded-lg">
|
<div class="dashboard-card dashboard-card--no-overflow">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="dashboard-card-body">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="dashboard-section-header">
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
<h3 class="dashboard-section-title">Your Properties</h3>
|
||||||
Your Properties
|
<a href="/landlord/properties" class="dashboard-section-link">
|
||||||
</h3>
|
|
||||||
<a
|
|
||||||
href="/landlord/properties"
|
|
||||||
class="text-sm text-blue-600 hover:text-blue-500"
|
|
||||||
>
|
|
||||||
View all
|
View all
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="dashboard-list-spaced">
|
||||||
{properties && properties.length > 0
|
{properties && properties.length > 0
|
||||||
? (
|
? (
|
||||||
properties.slice(0, 3).map((property) => (
|
properties.slice(0, 3).map((property) => (
|
||||||
<div
|
<div key={property.id} class="dashboard-row">
|
||||||
key={property.id}
|
|
||||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-900">
|
<p class="dashboard-row-label">
|
||||||
{property.address_1}
|
{property.address_1}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="dashboard-row-muted">
|
||||||
{property.city}, {property.state}
|
{property.city}, {property.state}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-400 capitalize">
|
<p class="dashboard-row-meta capitalize">
|
||||||
{property.rental_status}
|
{property.rental_status}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div style="text-align: right;">
|
||||||
<p class="text-sm font-medium text-gray-900">
|
<p class="dashboard-row-value">
|
||||||
${property.monthly_rent}/mo
|
${property.monthly_rent}/mo
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500">
|
<p class="dashboard-row-sub">
|
||||||
{property.bedrooms}bd/{property.bathrooms}ba
|
{property.bedrooms}bd/{property.bathrooms}ba
|
||||||
</p>
|
</p>
|
||||||
|
<a
|
||||||
|
href={`/landlord/properties/${property.id}`}
|
||||||
|
class="dashboard-btn-link"
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<div class="text-center py-4">
|
<div class="dashboard-empty">
|
||||||
<p class="text-sm text-gray-500">No properties yet</p>
|
<p class="dashboard-empty-text">No properties yet</p>
|
||||||
<a
|
<a
|
||||||
href="/landlord/properties/add"
|
href="/landlord/properties/add"
|
||||||
class="text-sm text-blue-600 hover:text-blue-500"
|
class="dashboard-empty-link"
|
||||||
>
|
>
|
||||||
Add your first property
|
Add your first property
|
||||||
</a>
|
</a>
|
||||||
@ -358,42 +352,32 @@ export default define.page(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Messages */}
|
{/* Recent Messages */}
|
||||||
<div class="bg-white shadow rounded-lg">
|
<div class="dashboard-card dashboard-card--no-overflow">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="dashboard-card-body">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="dashboard-section-header">
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
<h3 class="dashboard-section-title">Recent Messages</h3>
|
||||||
Recent Messages
|
<a href="/landlord/messages" class="dashboard-section-link">
|
||||||
</h3>
|
|
||||||
<a
|
|
||||||
href="/landlord/messages"
|
|
||||||
class="text-sm text-blue-600 hover:text-blue-500"
|
|
||||||
>
|
|
||||||
View all
|
View all
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="dashboard-list-spaced">
|
||||||
{recentMessages && recentMessages.length > 0
|
{recentMessages && recentMessages.length > 0
|
||||||
? (
|
? (
|
||||||
recentMessages.slice(0, 3).map((message) => (
|
recentMessages.slice(0, 3).map((message) => (
|
||||||
<div
|
<div key={message.id} class="dashboard-message-row">
|
||||||
key={message.id}
|
<div class="dashboard-message-avatar">
|
||||||
class="flex items-start space-x-3"
|
<span class="dashboard-message-avatar-text">
|
||||||
>
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<div class="w-8 h-8 bg-gray-300 rounded-full flex items-center justify-center">
|
|
||||||
<span class="text-xs font-medium text-gray-700">
|
|
||||||
{message.sender_name?.charAt(0) || "T"}
|
{message.sender_name?.charAt(0) || "T"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="dashboard-message-body">
|
||||||
<div class="flex-1 min-w-0">
|
<p class="dashboard-message-from">
|
||||||
<p class="text-sm font-medium text-gray-900">
|
|
||||||
{message.sender_name || "Tenant"}
|
{message.sender_name || "Tenant"}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-500 truncate">
|
<p class="dashboard-message-preview">
|
||||||
{message.subject}
|
{message.subject}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-400">
|
<p class="dashboard-message-meta">
|
||||||
{MakeNewDate(
|
{MakeNewDate(
|
||||||
(message as DashboardMessage)
|
(message as DashboardMessage)
|
||||||
.created_on as number,
|
.created_on as number,
|
||||||
@ -402,86 +386,18 @@ export default define.page(
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!message.is_read && (
|
{!message.is_read && (
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<div class="w-2 h-2 bg-blue-600 rounded-full">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<p class="text-sm text-gray-500">No messages yet</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pending Applications */}
|
|
||||||
<div class="bg-white shadow rounded-lg">
|
|
||||||
<div class="px-4 py-5 sm:p-6">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
|
||||||
Pending Applications
|
|
||||||
</h3>
|
|
||||||
<a
|
|
||||||
href="/landlord/applications"
|
|
||||||
class="text-sm text-blue-600 hover:text-blue-500"
|
|
||||||
>
|
|
||||||
View all
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-4">
|
|
||||||
{tenantApplications &&
|
|
||||||
tenantApplications.filter((app) =>
|
|
||||||
app.status === "pending"
|
|
||||||
).length > 0
|
|
||||||
? (
|
|
||||||
tenantApplications.filter((app) =>
|
|
||||||
app.status === "pending"
|
|
||||||
).slice(0, 3).map((application) => (
|
|
||||||
<div
|
<div
|
||||||
key={application.id}
|
class="dashboard-message-dot"
|
||||||
class="flex items-center justify-between p-3 bg-yellow-50 rounded-lg"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-gray-900">
|
|
||||||
{application.applicant_name}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
{application.applicant_email}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-400">
|
|
||||||
Applied {application.submitted_on
|
|
||||||
? new Date(application.submitted_on)
|
|
||||||
.toLocaleDateString()
|
|
||||||
: "Unknown date"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex space-x-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded"
|
|
||||||
>
|
|
||||||
Approve
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="text-xs bg-red-100 text-red-800 px-2 py-1 rounded"
|
|
||||||
>
|
|
||||||
Reject
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<div class="text-center py-4">
|
<div class="dashboard-empty">
|
||||||
<p class="text-sm text-gray-500">
|
<p class="dashboard-empty-text">No messages yet</p>
|
||||||
No pending applications
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -489,102 +405,25 @@ export default define.page(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pending Lease Approvals */}
|
{/* Pending Lease Approvals */}
|
||||||
<div class="bg-white shadow rounded-lg">
|
<LandlordPendingApprovals
|
||||||
<div class="px-4 py-5 sm:p-6">
|
approvals={pendingLeaseApprovals ?? []}
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
|
||||||
Pending Lease Approvals
|
|
||||||
</h3>
|
|
||||||
<a
|
|
||||||
href="/landlord/approvals"
|
|
||||||
class="text-sm text-blue-600 hover:text-blue-500"
|
|
||||||
>
|
|
||||||
View all
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-4">
|
|
||||||
{pendingLeaseApprovals && pendingLeaseApprovals.length > 0
|
|
||||||
? (
|
|
||||||
pendingLeaseApprovals.slice(0, 3).map((approval) => (
|
|
||||||
<div
|
|
||||||
key={approval.id}
|
|
||||||
class="flex items-center justify-between p-3 bg-blue-50 rounded-lg"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-gray-900">
|
|
||||||
{approval.property?.title ||
|
|
||||||
approval.property_address || "Property"}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
Tenant: {approval.tenant?.name ||
|
|
||||||
approval.tenant?.email || "Unknown"}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-400">
|
|
||||||
Requested {approval.approval_requested_at
|
|
||||||
? new Date(
|
|
||||||
approval.approval_requested_at as number,
|
|
||||||
)
|
|
||||||
.toLocaleDateString()
|
|
||||||
: "Unknown date"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex space-x-2">
|
|
||||||
<form method="POST" action="/api/lease/approve">
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="lease_id"
|
|
||||||
value={approval.id as string}
|
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded hover:bg-green-200"
|
|
||||||
>
|
|
||||||
Approve
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<form method="POST" action="/api/lease/reject">
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="lease_id"
|
|
||||||
value={approval.id as string}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="text-xs bg-red-100 text-red-800 px-2 py-1 rounded hover:bg-red-200"
|
|
||||||
>
|
|
||||||
Reject
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
No pending lease approvals
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Maintenance Requests */}
|
{/* Maintenance Requests */}
|
||||||
<div class="bg-white shadow rounded-lg">
|
<div class="dashboard-card dashboard-card--no-overflow">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="dashboard-card-body">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="dashboard-section-header">
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
<h3 class="dashboard-section-title">
|
||||||
Maintenance Requests
|
Maintenance Requests
|
||||||
</h3>
|
</h3>
|
||||||
<a
|
<a
|
||||||
href="/landlord/maintenance"
|
href="/landlord/maintenance"
|
||||||
class="text-sm text-blue-600 hover:text-blue-500"
|
class="dashboard-section-link"
|
||||||
>
|
>
|
||||||
View all
|
View all
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="dashboard-list-spaced">
|
||||||
{maintenanceRequests &&
|
{maintenanceRequests &&
|
||||||
maintenanceRequests.filter((req) => req.status === "open")
|
maintenanceRequests.filter((req) => req.status === "open")
|
||||||
.length > 0
|
.length > 0
|
||||||
@ -593,16 +432,16 @@ export default define.page(
|
|||||||
.slice(0, 3).map((request) => (
|
.slice(0, 3).map((request) => (
|
||||||
<div
|
<div
|
||||||
key={request.id}
|
key={request.id}
|
||||||
class="flex items-center justify-between p-3 bg-red-50 rounded-lg"
|
class="dashboard-row dashboard-row--red"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-900">
|
<p class="dashboard-row-label">
|
||||||
{request.description}
|
{request.description}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-500 capitalize">
|
<p class="dashboard-row-muted capitalize">
|
||||||
Priority: {request.priority}
|
Priority: {request.priority}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-400">
|
<p class="dashboard-row-meta">
|
||||||
{request.created_on
|
{request.created_on
|
||||||
? new Date(request.created_on)
|
? new Date(request.created_on)
|
||||||
.toLocaleDateString()
|
.toLocaleDateString()
|
||||||
@ -610,7 +449,7 @@ export default define.page(
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
<span class="dashboard-badge dashboard-badge--red-pill">
|
||||||
{request.status}
|
{request.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -618,8 +457,8 @@ export default define.page(
|
|||||||
))
|
))
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<div class="text-center py-4">
|
<div class="dashboard-empty">
|
||||||
<p class="text-sm text-gray-500">
|
<p class="dashboard-empty-text">
|
||||||
No open maintenance requests
|
No open maintenance requests
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
223
routes/landlord/messages/[id]/index.tsx
Normal file
223
routes/landlord/messages/[id]/index.tsx
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
// routes/landlord/messages/[id]/index.tsx
|
||||||
|
import { define } from "../../../../utils.ts";
|
||||||
|
import { page } from "fresh";
|
||||||
|
import {
|
||||||
|
getMessageThread,
|
||||||
|
markThreadAsRead,
|
||||||
|
} from "../../../../services/messageService.ts";
|
||||||
|
import { Message } from "../../../../db/interfaces.ts";
|
||||||
|
import { AlertMessage } from "../../../tenant/maintenance/new/components/AlertMessage.tsx";
|
||||||
|
|
||||||
|
interface MessageThreadPageData {
|
||||||
|
thread: Message[];
|
||||||
|
error?: string;
|
||||||
|
currentUserId: string;
|
||||||
|
rootMessageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler = define.handlers({
|
||||||
|
async GET(ctx) {
|
||||||
|
const { user_id } = ctx.state.data;
|
||||||
|
const messageId = ctx.params.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const thread = await getMessageThread(messageId);
|
||||||
|
|
||||||
|
if (!thread || thread.length === 0) {
|
||||||
|
return page<MessageThreadPageData>({
|
||||||
|
thread: [],
|
||||||
|
error: "Message thread not found.",
|
||||||
|
currentUserId: user_id,
|
||||||
|
rootMessageId: messageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootMessageId = thread[0].parent_message_id || thread[0].id;
|
||||||
|
|
||||||
|
await markThreadAsRead(user_id, rootMessageId);
|
||||||
|
|
||||||
|
return page<MessageThreadPageData>({
|
||||||
|
thread,
|
||||||
|
currentUserId: user_id,
|
||||||
|
rootMessageId,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error loading message thread:", err);
|
||||||
|
return page<MessageThreadPageData>({
|
||||||
|
thread: [],
|
||||||
|
error: "Failed to load message thread.",
|
||||||
|
currentUserId: user_id,
|
||||||
|
rootMessageId: messageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function MessageBubble(
|
||||||
|
{ message, isOutgoing }: { message: Message; isOutgoing: boolean },
|
||||||
|
) {
|
||||||
|
const bubbleClass = isOutgoing
|
||||||
|
? "bg-blue-500 text-white self-end rounded-br-none"
|
||||||
|
: "bg-gray-200 text-gray-800 self-start rounded-tl-none";
|
||||||
|
const alignmentClass = isOutgoing ? "justify-end" : "justify-start";
|
||||||
|
|
||||||
|
const date = message.created_on
|
||||||
|
? new Date(message.created_on).toLocaleString()
|
||||||
|
: "Unknown Date";
|
||||||
|
|
||||||
|
const attachments = Array.isArray(message.attachments_json)
|
||||||
|
? message.attachments_json
|
||||||
|
: message.attachments_json
|
||||||
|
? JSON.parse(message.attachments_json as unknown as string)
|
||||||
|
: [];
|
||||||
|
const content = message.content_encrypted || message.content || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={`flex ${alignmentClass} mb-4`}>
|
||||||
|
<div class={`max-w-3/4 p-4 rounded-lg shadow-md ${bubbleClass}`}>
|
||||||
|
<p class="text-xs opacity-80 mb-1">
|
||||||
|
{isOutgoing ? "You" : "Tenant"} - {date}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm whitespace-pre-wrap">{content}</p>
|
||||||
|
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div class="mt-3 pt-3 border-t border-opacity-30">
|
||||||
|
<p class="text-xs font-semibold mb-1">Attachments:</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{attachments.map((att: { url: string; name: string }) => (
|
||||||
|
<li key={att.url}>
|
||||||
|
<a
|
||||||
|
href={att.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-xs underline hover:text-opacity-80"
|
||||||
|
>
|
||||||
|
{att.name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default define.page(function LandlordMessageThreadPage({ data }) {
|
||||||
|
const { thread, error, currentUserId, rootMessageId } =
|
||||||
|
data as MessageThreadPageData;
|
||||||
|
|
||||||
|
const rootMessage = thread[0];
|
||||||
|
const subject = rootMessage?.subject || "Conversation";
|
||||||
|
const replyRecipientId = rootMessage
|
||||||
|
? (rootMessage.sender_id === currentUserId
|
||||||
|
? rootMessage.recipient_id
|
||||||
|
: rootMessage.sender_id)
|
||||||
|
: null;
|
||||||
|
const replySubject = rootMessage?.subject
|
||||||
|
? `Re: ${rootMessage.subject}`
|
||||||
|
: "Re: Message";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="min-h-screen bg-gray-50 landlord-route">
|
||||||
|
<div class="bg-white shadow">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="py-6 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Message Thread</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Subject: {subject}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<a
|
||||||
|
href="/landlord/messages"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 shadow-sm"
|
||||||
|
>
|
||||||
|
All messages
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/landlord/dashboard"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 shadow-sm"
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8 py-8">
|
||||||
|
{error && <AlertMessage type="error">{error}</AlertMessage>}
|
||||||
|
|
||||||
|
<div class="flex flex-col space-y-4 p-6 bg-white shadow overflow-hidden sm:rounded-lg">
|
||||||
|
{thread.map((message) => (
|
||||||
|
<MessageBubble
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
isOutgoing={message.sender_id === currentUserId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{replyRecipientId && (
|
||||||
|
<div class="mt-8 p-6 bg-white shadow overflow-hidden sm:rounded-lg">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Reply to thread
|
||||||
|
</h2>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="/api/create/message"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="sender"
|
||||||
|
value={`/landlord/messages/${rootMessageId}`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="lease_id"
|
||||||
|
value={rootMessage?.lease_id ?? ""}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="recipient_id"
|
||||||
|
value={replyRecipientId}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="message_type"
|
||||||
|
value={rootMessage?.message_type ?? "general"}
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="subject" value={replySubject} />
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="reply-content"
|
||||||
|
class="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Your reply
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="reply-content"
|
||||||
|
name="content"
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
placeholder="Type your reply..."
|
||||||
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
Send reply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -4,8 +4,10 @@ import { page } from "fresh";
|
|||||||
import { SessionCookie } from "../../auth/callback.ts";
|
import { SessionCookie } from "../../auth/callback.ts";
|
||||||
import {
|
import {
|
||||||
deleteMessagesForUser,
|
deleteMessagesForUser,
|
||||||
getMessagesForUser,
|
getMessageThread,
|
||||||
MessagePage,
|
getThreadsForUser,
|
||||||
|
ThreadPage,
|
||||||
|
ThreadSummary,
|
||||||
} from "../../../services/messageService.ts";
|
} from "../../../services/messageService.ts";
|
||||||
import LandlordMessageSelection from "../../../islands/LandlordMessageSelection.tsx";
|
import LandlordMessageSelection from "../../../islands/LandlordMessageSelection.tsx";
|
||||||
import LandlordMessageThreads from "../../../islands/LandlordMessageThreads.tsx";
|
import LandlordMessageThreads from "../../../islands/LandlordMessageThreads.tsx";
|
||||||
@ -26,7 +28,7 @@ interface Filters {
|
|||||||
|
|
||||||
interface MessagesPageData {
|
interface MessagesPageData {
|
||||||
stateData: SessionCookie;
|
stateData: SessionCookie;
|
||||||
messagePage: MessagePage;
|
threadPage: ThreadPage;
|
||||||
filters: Filters;
|
filters: Filters;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
@ -103,7 +105,7 @@ export const handler = define.handlers({
|
|||||||
);
|
);
|
||||||
const search = url.searchParams.get("q")?.trim() || "";
|
const search = url.searchParams.get("q")?.trim() || "";
|
||||||
|
|
||||||
const messagePage = await getMessagesForUser(user_id, {
|
const threadPage = await getThreadsForUser(user_id, {
|
||||||
page: pageNumber,
|
page: pageNumber,
|
||||||
pageSize,
|
pageSize,
|
||||||
sort,
|
sort,
|
||||||
@ -115,7 +117,7 @@ export const handler = define.handlers({
|
|||||||
|
|
||||||
return page({
|
return page({
|
||||||
stateData,
|
stateData,
|
||||||
messagePage,
|
threadPage,
|
||||||
filters: {
|
filters: {
|
||||||
status,
|
status,
|
||||||
type,
|
type,
|
||||||
@ -129,12 +131,12 @@ export const handler = define.handlers({
|
|||||||
console.error("Error loading messages:", error);
|
console.error("Error loading messages:", error);
|
||||||
return page({
|
return page({
|
||||||
stateData,
|
stateData,
|
||||||
messagePage: {
|
threadPage: {
|
||||||
messages: [],
|
threads: [],
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
total: 0,
|
total: 0,
|
||||||
totalMessages: 0,
|
totalThreads: 0,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
unread: 0,
|
unread: 0,
|
||||||
},
|
},
|
||||||
@ -168,12 +170,25 @@ export const handler = define.handlers({
|
|||||||
return Response.redirect(redirectUrl, 303);
|
return Response.redirect(redirectUrl, 303);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ids = formData.getAll("message_ids")
|
const rootIds = formData.getAll("message_ids")
|
||||||
.map((value) => value.toString())
|
.map((value) => value.toString())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
if (ids.length > 0) {
|
if (rootIds.length > 0) {
|
||||||
await deleteMessagesForUser(user_id, ids);
|
const messageIds: string[] = [];
|
||||||
|
for (const rootId of rootIds) {
|
||||||
|
const thread = await getMessageThread(rootId);
|
||||||
|
if (thread) {
|
||||||
|
for (const m of thread) {
|
||||||
|
if (m.sender_id === user_id || m.recipient_id === user_id) {
|
||||||
|
messageIds.push(m.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (messageIds.length > 0) {
|
||||||
|
await deleteMessagesForUser(user_id, messageIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.redirect(redirectUrl, 303);
|
return Response.redirect(redirectUrl, 303);
|
||||||
@ -186,12 +201,86 @@ export const handler = define.handlers({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default define.page(function LandlordMessages({ data }) {
|
export default define.page(function LandlordMessages({ data }) {
|
||||||
const { stateData, messagePage, filters, error } = data as MessagesPageData;
|
const { stateData, threadPage, filters, error } = data as MessagesPageData;
|
||||||
const { messages, page, pageSize, total, totalPages } = messagePage;
|
const { threads, page, pageSize, total, totalPages } = threadPage;
|
||||||
|
const totalThreads = threadPage.totalThreads ?? total;
|
||||||
|
const unread = threadPage.unread ?? 0;
|
||||||
const startIndex = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
const startIndex = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||||
const endIndex = Math.min(total, page * pageSize);
|
const endIndex = Math.min(total, page * pageSize);
|
||||||
const returnTo = buildPageUrl(filters, page);
|
const returnTo = buildPageUrl(filters, page);
|
||||||
const hasMessages = messages.length > 0;
|
const hasThreads = threads.length > 0;
|
||||||
|
|
||||||
|
const ThreadRow = ({ thread }: { thread: ThreadSummary }) => {
|
||||||
|
const msg = thread.latestMessage;
|
||||||
|
const isOutbound = msg.sender_id === stateData.user_id;
|
||||||
|
const counterpart = isOutbound
|
||||||
|
? (msg.recipient_name ?? "Recipient")
|
||||||
|
: (msg.sender_name ?? "Tenant");
|
||||||
|
const contentPreview = (msg.content_encrypted || msg.content || "")
|
||||||
|
.substring(0, 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
class={`p-4 border-b border-gray-200 transition-colors ${
|
||||||
|
thread.hasUnread
|
||||||
|
? "bg-blue-50 hover:bg-blue-100"
|
||||||
|
: "bg-white hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="message_ids"
|
||||||
|
value={thread.rootMessageId}
|
||||||
|
form="bulk-delete-form"
|
||||||
|
class="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-sm font-medium text-gray-900 flex items-center gap-2 flex-wrap">
|
||||||
|
{thread.hasUnread && (
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-blue-600 text-white">
|
||||||
|
New!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{thread.subject}
|
||||||
|
{thread.messageCount > 1 && (
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
({thread.messageCount} messages)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 truncate">
|
||||||
|
<span class="font-semibold text-gray-700">
|
||||||
|
{isOutbound ? "To" : "From"} {counterpart}:
|
||||||
|
</span>{" "}
|
||||||
|
{contentPreview || "No content preview"}
|
||||||
|
{contentPreview.length >= 100 ? "..." : ""}
|
||||||
|
</p>
|
||||||
|
{msg.property_address && (
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">
|
||||||
|
Property: {msg.property_address}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
{msg.created_on
|
||||||
|
? new Date(msg.created_on).toLocaleDateString()
|
||||||
|
: "—"}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`/landlord/messages/${thread.rootMessageId}`}
|
||||||
|
class="text-sm text-blue-600 hover:text-blue-500"
|
||||||
|
>
|
||||||
|
View Thread
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="min-h-screen bg-gray-50 landlord-route">
|
<div class="min-h-screen bg-gray-50 landlord-route">
|
||||||
@ -208,10 +297,16 @@ export default define.page(function LandlordMessages({ data }) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<a
|
<a
|
||||||
href="/landlord/dashboard"
|
href="/landlord/messages/new"
|
||||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 shadow-sm"
|
||||||
>
|
>
|
||||||
Back to Dashboard
|
New Message
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/landlord/dashboard"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 shadow-sm"
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -225,7 +320,7 @@ export default define.page(function LandlordMessages({ data }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form method="GET" class="bg-white shadow rounded-lg p-6 mb-8">
|
<form method="GET" class="bg-white shadow rounded-lg p-6 mb-6">
|
||||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-5">
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-5">
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-2">
|
||||||
<label class="block text-sm font-medium text-gray-700">
|
<label class="block text-sm font-medium text-gray-700">
|
||||||
@ -339,14 +434,14 @@ export default define.page(function LandlordMessages({ data }) {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="bg-white shadow rounded-lg"
|
class="bg-white shadow overflow-hidden sm:rounded-lg"
|
||||||
data-message-form
|
data-message-form
|
||||||
data-status-filter={filters.status}
|
data-status-filter={filters.status}
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
id="bulk-delete-form"
|
id="bulk-delete-form"
|
||||||
class="px-4 py-5 sm:px-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
class="px-4 py-5 sm:px-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between border-b border-gray-200"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="return_to" value={returnTo} />
|
<input type="hidden" name="return_to" value={returnTo} />
|
||||||
<div>
|
<div>
|
||||||
@ -354,7 +449,12 @@ export default define.page(function LandlordMessages({ data }) {
|
|||||||
All Messages
|
All Messages
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
showing {startIndex}-{endIndex} of {total} messages
|
Showing {startIndex}-{endIndex} of {totalThreads} threads
|
||||||
|
{unread > 0 && (
|
||||||
|
<span class="ml-2 text-blue-600 font-semibold">
|
||||||
|
({unread} unread)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-500">
|
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-500">
|
||||||
@ -363,7 +463,7 @@ export default define.page(function LandlordMessages({ data }) {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
data-select-all
|
data-select-all
|
||||||
disabled={!hasMessages}
|
disabled={!hasThreads}
|
||||||
/>
|
/>
|
||||||
<span>Select all</span>
|
<span>Select all</span>
|
||||||
</label>
|
</label>
|
||||||
@ -383,204 +483,20 @@ export default define.page(function LandlordMessages({ data }) {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{messages.length === 0
|
{threads.length === 0
|
||||||
? (
|
? (
|
||||||
<div class="px-6 py-12 text-center text-sm text-gray-500">
|
<div class="px-6 py-12 text-center">
|
||||||
No messages match the selected filters.
|
<p class="text-sm text-gray-500">
|
||||||
|
No threads match the selected filters.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<div class="divide-y divide-gray-200">
|
<ul class="divide-y divide-gray-200">
|
||||||
{messages.map((message) => {
|
{threads.map((thread) => (
|
||||||
const isOutbound = message.sender_id === stateData.user_id;
|
<ThreadRow key={thread.rootMessageId} thread={thread} />
|
||||||
const counterpart = isOutbound
|
))}
|
||||||
? message.recipient_name || "Recipient"
|
</ul>
|
||||||
: message.sender_name || "Tenant";
|
|
||||||
const replyRecipientId = isOutbound
|
|
||||||
? message.recipient_id
|
|
||||||
: message.sender_id;
|
|
||||||
const replySubject = message.subject
|
|
||||||
? `Re: ${message.subject}`
|
|
||||||
: "Re: Message";
|
|
||||||
const canReply = Boolean(replyRecipientId);
|
|
||||||
const isUnreadInbound = !isOutbound &&
|
|
||||||
message.recipient_id === stateData.user_id &&
|
|
||||||
!message.is_read;
|
|
||||||
const subjectClass = isUnreadInbound
|
|
||||||
? "mt-2 text-sm font-semibold text-gray-900"
|
|
||||||
: "mt-2 text-sm font-medium text-gray-700";
|
|
||||||
const previewClass = isUnreadInbound
|
|
||||||
? "mt-1 text-sm text-gray-700 line-clamp-2"
|
|
||||||
: "mt-1 text-sm text-gray-500 line-clamp-2";
|
|
||||||
const panelId = `message-panel-${message.id}`;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={message.id}
|
|
||||||
class="px-6 py-5"
|
|
||||||
data-message-row
|
|
||||||
data-message-status={isUnreadInbound ? "unread" : "read"}
|
|
||||||
>
|
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="message_ids"
|
|
||||||
value={message.id}
|
|
||||||
form="bulk-delete-form"
|
|
||||||
class="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
||||||
data-message-checkbox
|
|
||||||
aria-label={`Select message ${
|
|
||||||
message.subject || "No subject"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<div class="flex-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-full text-left rounded-lg p-2 -m-2 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
data-message-toggle
|
|
||||||
data-message-id={message.id}
|
|
||||||
data-message-unread={isUnreadInbound
|
|
||||||
? "true"
|
|
||||||
: "false"}
|
|
||||||
aria-expanded="false"
|
|
||||||
aria-controls={panelId}
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-xs uppercase tracking-wide text-gray-400">
|
|
||||||
{isOutbound ? "To" : "From"}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm font-medium text-gray-900">
|
|
||||||
{counterpart}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class={subjectClass}
|
|
||||||
data-message-subject
|
|
||||||
>
|
|
||||||
{message.subject || "No subject"}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class={previewClass}
|
|
||||||
data-message-preview
|
|
||||||
>
|
|
||||||
{message.content ||
|
|
||||||
"Message content is unavailable."}
|
|
||||||
</p>
|
|
||||||
{message.property_address && (
|
|
||||||
<p class="mt-2 text-xs text-gray-400">
|
|
||||||
Property: {message.property_address}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col items-start gap-2 text-sm text-gray-500 lg:items-end">
|
|
||||||
<span>
|
|
||||||
{message.message_type
|
|
||||||
? message.message_type.charAt(0)
|
|
||||||
.toUpperCase() +
|
|
||||||
message.message_type.slice(1)
|
|
||||||
: "General"}
|
|
||||||
</span>
|
|
||||||
<span class="inline-flex items-center gap-2">
|
|
||||||
{message.created_on
|
|
||||||
? new Date(message.created_on)
|
|
||||||
.toLocaleDateString()
|
|
||||||
: "Unknown date"}
|
|
||||||
{isUnreadInbound && (
|
|
||||||
<span
|
|
||||||
class="h-2 w-2 rounded-full bg-blue-600"
|
|
||||||
data-unread-dot
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
id={panelId}
|
|
||||||
class="mt-4 hidden"
|
|
||||||
data-message-panel
|
|
||||||
>
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
|
||||||
<p class="text-sm text-gray-700 whitespace-pre-line">
|
|
||||||
{message.content ||
|
|
||||||
"Message content is unavailable."}
|
|
||||||
</p>
|
|
||||||
{message.property_address && (
|
|
||||||
<p class="mt-3 text-xs text-gray-500">
|
|
||||||
Property: {message.property_address}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div class="mt-4">
|
|
||||||
<p class="text-sm font-semibold text-gray-900">
|
|
||||||
Quick reply
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
|
||||||
Subject: {replySubject}
|
|
||||||
</p>
|
|
||||||
{canReply
|
|
||||||
? (
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="/api/create/message"
|
|
||||||
class="mt-3 space-y-3"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="sender"
|
|
||||||
value={returnTo}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="lease_id"
|
|
||||||
value={message.lease_id || ""}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="recipient_id"
|
|
||||||
value={replyRecipientId || ""}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="message_type"
|
|
||||||
value={message.message_type || "general"}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="subject"
|
|
||||||
value={replySubject}
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
name="content"
|
|
||||||
rows={6}
|
|
||||||
required
|
|
||||||
placeholder="Type your reply..."
|
|
||||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
|
||||||
/>
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Send reply
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<p class="mt-3 text-sm text-gray-500">
|
|
||||||
Reply is unavailable for this message.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div class="px-6 py-4 border-t border-gray-200 flex items-center justify-between text-sm">
|
<div class="px-6 py-4 border-t border-gray-200 flex items-center justify-between text-sm">
|
||||||
|
|||||||
699
routes/landlord/properties/[id]/index.tsx
Normal file
699
routes/landlord/properties/[id]/index.tsx
Normal file
@ -0,0 +1,699 @@
|
|||||||
|
// routes/landlord/properties/[id]/index.tsx
|
||||||
|
// Property details page with complete payment history and expenses
|
||||||
|
|
||||||
|
import { define } from "../../../../utils.ts";
|
||||||
|
import { page } from "fresh";
|
||||||
|
import { getPropertyById } from "../../../../services/propertyService.ts";
|
||||||
|
import { getLeasesByProperty } from "../../../../services/leaseService.ts";
|
||||||
|
import { getUserById } from "../../../../services/userService.ts";
|
||||||
|
import {
|
||||||
|
getExpensesByProperty,
|
||||||
|
type PropertyExpense,
|
||||||
|
} from "../../../../services/expenseService.ts";
|
||||||
|
import {
|
||||||
|
getRentHistory,
|
||||||
|
type RentChange,
|
||||||
|
} from "../../../../services/rentChangeService.ts";
|
||||||
|
import { db } from "../../../../db/db.ts";
|
||||||
|
import { leases, payment_transactions } from "../../../../db/schema.ts";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Property, Lease, User } from "../../../../db/interfaces.ts";
|
||||||
|
|
||||||
|
interface PropertyDetailsData {
|
||||||
|
property: Property | null;
|
||||||
|
leases: Lease[];
|
||||||
|
payments: Array<{
|
||||||
|
id: string;
|
||||||
|
amount: number | null;
|
||||||
|
payment_date: number | null;
|
||||||
|
payment_method: string | null;
|
||||||
|
transaction_status: string | null;
|
||||||
|
tenant_name?: string;
|
||||||
|
}>;
|
||||||
|
expenses: PropertyExpense[];
|
||||||
|
rentHistory: Array<{
|
||||||
|
effective_date: number;
|
||||||
|
monthly_rent: number;
|
||||||
|
previous_rent: number | null;
|
||||||
|
reason: string | null;
|
||||||
|
is_current: boolean;
|
||||||
|
}>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPropertyAddress(property: Property | null): string {
|
||||||
|
if (!property) return "";
|
||||||
|
return [
|
||||||
|
property.address_1,
|
||||||
|
property.address_2,
|
||||||
|
property.city,
|
||||||
|
property.state,
|
||||||
|
property.zip,
|
||||||
|
].filter(Boolean).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler = define.handlers({
|
||||||
|
async GET(ctx) {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch property
|
||||||
|
const property = await getPropertyById(id);
|
||||||
|
|
||||||
|
if (!property) {
|
||||||
|
return page({
|
||||||
|
property: null,
|
||||||
|
leases: [],
|
||||||
|
payments: [],
|
||||||
|
expenses: [],
|
||||||
|
rentHistory: [],
|
||||||
|
error: "Property not found",
|
||||||
|
} as PropertyDetailsData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all leases for this property
|
||||||
|
const leases = await getLeasesByProperty(id);
|
||||||
|
|
||||||
|
// Fetch all payment transactions for these leases
|
||||||
|
const payments: PropertyDetailsData["payments"] = [];
|
||||||
|
|
||||||
|
for (const lease of leases) {
|
||||||
|
if (lease.id) {
|
||||||
|
const leasePayments = await db.select({
|
||||||
|
id: payment_transactions.id,
|
||||||
|
amount: payment_transactions.amount,
|
||||||
|
payment_date: payment_transactions.payment_date,
|
||||||
|
payment_method: payment_transactions.payment_method,
|
||||||
|
transaction_status: payment_transactions.transaction_status,
|
||||||
|
})
|
||||||
|
.from(payment_transactions)
|
||||||
|
.where(eq(payment_transactions.lease_id, lease.id));
|
||||||
|
|
||||||
|
// Fetch tenant name for the lease
|
||||||
|
let tenant_name = undefined;
|
||||||
|
if (lease.tenant_id) {
|
||||||
|
const tenant = await getUserById(lease.tenant_id);
|
||||||
|
tenant_name = tenant?.name || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const payment of leasePayments) {
|
||||||
|
payments.push({
|
||||||
|
...payment,
|
||||||
|
tenant_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort payments by date (newest first)
|
||||||
|
payments.sort((a, b) =>
|
||||||
|
(b.payment_date || 0) - (a.payment_date || 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch all expenses for this property
|
||||||
|
const expenses = await getExpensesByProperty(id);
|
||||||
|
|
||||||
|
// Fetch rent history
|
||||||
|
const rentHistory = await getRentHistory(id);
|
||||||
|
|
||||||
|
return page({
|
||||||
|
property,
|
||||||
|
leases,
|
||||||
|
payments,
|
||||||
|
expenses,
|
||||||
|
rentHistory,
|
||||||
|
} as PropertyDetailsData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading property details:", error);
|
||||||
|
return page({
|
||||||
|
property: null,
|
||||||
|
leases: [],
|
||||||
|
payments: [],
|
||||||
|
expenses: [],
|
||||||
|
rentHistory: [],
|
||||||
|
error: "Failed to load property details",
|
||||||
|
} as PropertyDetailsData);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default define.page(function PropertyDetailsPage({ data }: { data: PropertyDetailsData }) {
|
||||||
|
const { property, leases, payments, expenses, rentHistory, error } = data;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div class="dashboard-page landlord-route">
|
||||||
|
<div class="dashboard-error-page">
|
||||||
|
<div class="dashboard-error-inner">
|
||||||
|
<h1 class="dashboard-error-title">Error</h1>
|
||||||
|
<p class="dashboard-error-text">{error}</p>
|
||||||
|
<a href="/landlord/properties" class="dashboard-error-link">
|
||||||
|
Back to Properties
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!property) {
|
||||||
|
return (
|
||||||
|
<div class="dashboard-page landlord-route">
|
||||||
|
<div class="dashboard-error-page">
|
||||||
|
<div class="dashboard-error-inner">
|
||||||
|
<h1 class="dashboard-error-title">Property Not Found</h1>
|
||||||
|
<p class="dashboard-error-text">The property you're looking for doesn't exist.</p>
|
||||||
|
<a href="/landlord/properties" class="dashboard-error-link">
|
||||||
|
Back to Properties
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const propertyAddress = buildPropertyAddress(property);
|
||||||
|
const totalPayments = payments.reduce((sum, p) => sum + (p.amount || 0), 0);
|
||||||
|
const totalExpenses = expenses.reduce((sum, e) => sum + (e.amount || 0), 0);
|
||||||
|
const netIncome = totalPayments - totalExpenses;
|
||||||
|
|
||||||
|
// Calculate expenses by category
|
||||||
|
const expensesByCategory: Record<string, number> = {};
|
||||||
|
for (const expense of expenses) {
|
||||||
|
const category = expense.category || "uncategorized";
|
||||||
|
expensesByCategory[category] = (expensesByCategory[category] || 0) + (expense.amount || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="dashboard-page landlord-route">
|
||||||
|
{/* Header */}
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<div class="dashboard-header-inner">
|
||||||
|
<div class="dashboard-header-row">
|
||||||
|
<div>
|
||||||
|
<h1 class="dashboard-header-title">Property Details</h1>
|
||||||
|
<p class="dashboard-header-subtitle">
|
||||||
|
{property.title || propertyAddress}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-header-actions">
|
||||||
|
<a
|
||||||
|
href="/landlord/properties"
|
||||||
|
class="dashboard-btn dashboard-btn--secondary"
|
||||||
|
>
|
||||||
|
Back to Properties
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`/landlord/properties/${property.id}/edit`}
|
||||||
|
class="dashboard-btn dashboard-btn--primary"
|
||||||
|
>
|
||||||
|
Edit Property
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-content">
|
||||||
|
{/* Property Info Card */}
|
||||||
|
<div class="dashboard-card dashboard-section-mb">
|
||||||
|
<div class="dashboard-card-body">
|
||||||
|
<h3 class="dashboard-section-title dashboard-section-title--mb">
|
||||||
|
Property Information
|
||||||
|
</h3>
|
||||||
|
<div class="dashboard-property-grid">
|
||||||
|
<div>
|
||||||
|
<dt class="dashboard-stat-label">Address</dt>
|
||||||
|
<dd class="dashboard-stat-value">
|
||||||
|
{propertyAddress || "N/A"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="dashboard-stat-label">Monthly Rent</dt>
|
||||||
|
<dd class="dashboard-stat-value">
|
||||||
|
${property.monthly_rent?.toLocaleString() || "N/A"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="dashboard-stat-label">Status</dt>
|
||||||
|
<dd>
|
||||||
|
<span class={`dashboard-badge capitalize ${
|
||||||
|
property.rental_status === "occupied"
|
||||||
|
? "dashboard-badge--green"
|
||||||
|
: property.rental_status === "available"
|
||||||
|
? "dashboard-badge--blue"
|
||||||
|
: "dashboard-badge--yellow"
|
||||||
|
}`}>
|
||||||
|
{property.rental_status || "unknown"}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="dashboard-stat-label">Bedrooms / Bathrooms</dt>
|
||||||
|
<dd class="dashboard-stat-value">
|
||||||
|
{property.bedrooms || 0}bd / {property.bathrooms || 0}ba
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="dashboard-stat-label">Square Footage</dt>
|
||||||
|
<dd class="dashboard-stat-value">
|
||||||
|
{property.square_footage?.toLocaleString() || "N/A"} sq ft
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="dashboard-stat-label">Property Type</dt>
|
||||||
|
<dd class="dashboard-stat-value capitalize">
|
||||||
|
{property.property_type || "N/A"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Financial Summary */}
|
||||||
|
<div class="dashboard-stats-grid dashboard-section-mb">
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<div class="dashboard-card-body">
|
||||||
|
<div class="dashboard-stat-card">
|
||||||
|
<div class="dashboard-stat-icon-wrap">
|
||||||
|
<svg
|
||||||
|
class="dashboard-stat-icon dashboard-stat-icon--green"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-stat-content">
|
||||||
|
<dl>
|
||||||
|
<dt class="dashboard-stat-label">Total Payments Received</dt>
|
||||||
|
<dd class="dashboard-stat-value">
|
||||||
|
${totalPayments.toLocaleString()}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<div class="dashboard-card-body">
|
||||||
|
<div class="dashboard-stat-card">
|
||||||
|
<div class="dashboard-stat-icon-wrap">
|
||||||
|
<svg
|
||||||
|
class="dashboard-stat-icon dashboard-stat-icon--red"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-stat-content">
|
||||||
|
<dl>
|
||||||
|
<dt class="dashboard-stat-label">Total Expenses</dt>
|
||||||
|
<dd class="dashboard-stat-value">
|
||||||
|
${totalExpenses.toLocaleString()}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<div class="dashboard-card-body">
|
||||||
|
<div class="dashboard-stat-card">
|
||||||
|
<div class="dashboard-stat-icon-wrap">
|
||||||
|
<svg
|
||||||
|
class="dashboard-stat-icon dashboard-stat-icon--blue"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-stat-content">
|
||||||
|
<dl>
|
||||||
|
<dt class="dashboard-stat-label">Net Income</dt>
|
||||||
|
<dd class={`dashboard-stat-value ${
|
||||||
|
netIncome >= 0 ? "text-success" : "text-danger"
|
||||||
|
}`}>
|
||||||
|
${netIncome.toLocaleString()}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<div class="dashboard-card-body">
|
||||||
|
<div class="dashboard-stat-card">
|
||||||
|
<div class="dashboard-stat-icon-wrap">
|
||||||
|
<svg
|
||||||
|
class="dashboard-stat-icon"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-stat-content">
|
||||||
|
<dl>
|
||||||
|
<dt class="dashboard-stat-label">Active Leases</dt>
|
||||||
|
<dd class="dashboard-stat-value">
|
||||||
|
{leases.filter((l) => l.lease_status === "active").length}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two column layout */}
|
||||||
|
<div class="dashboard-two-col">
|
||||||
|
{/* Payment History */}
|
||||||
|
<div class="dashboard-card dashboard-card--no-overflow dashboard-card--full-width">
|
||||||
|
<div class="dashboard-card-body">
|
||||||
|
<div class="dashboard-section-header">
|
||||||
|
<h3 class="dashboard-section-title">Payment History</h3>
|
||||||
|
<span class="dashboard-section-subtitle">
|
||||||
|
{payments.length} payments received
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-table-wrap">
|
||||||
|
<table class="dashboard-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="dashboard-table-th">Date</th>
|
||||||
|
<th class="dashboard-table-th">Tenant</th>
|
||||||
|
<th class="dashboard-table-th">Method</th>
|
||||||
|
<th class="dashboard-table-th">Amount</th>
|
||||||
|
<th class="dashboard-table-th">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{payments && payments.length > 0
|
||||||
|
? (
|
||||||
|
payments.map((payment) => (
|
||||||
|
<tr key={payment.id}>
|
||||||
|
<td class="dashboard-table-td">
|
||||||
|
{payment.payment_date
|
||||||
|
? new Date(payment.payment_date)
|
||||||
|
.toLocaleDateString()
|
||||||
|
: "N/A"}
|
||||||
|
</td>
|
||||||
|
<td class="dashboard-table-td">
|
||||||
|
{payment.tenant_name || "Unknown"}
|
||||||
|
</td>
|
||||||
|
<td class="dashboard-table-td capitalize">
|
||||||
|
{payment.payment_method || "N/A"}
|
||||||
|
</td>
|
||||||
|
<td class="dashboard-table-td">
|
||||||
|
${payment.amount?.toLocaleString() || "0"}
|
||||||
|
</td>
|
||||||
|
<td class="dashboard-table-td">
|
||||||
|
<span
|
||||||
|
class={`dashboard-badge ${
|
||||||
|
payment.transaction_status === "completed"
|
||||||
|
? "dashboard-badge--green"
|
||||||
|
: payment.transaction_status === "pending"
|
||||||
|
? "dashboard-badge--yellow"
|
||||||
|
: "dashboard-badge--red"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{payment.transaction_status || "unknown"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<tr>
|
||||||
|
<td colspan={5} class="dashboard-table-empty">
|
||||||
|
No payment history yet
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expenses by Category */}
|
||||||
|
{Object.keys(expensesByCategory).length > 0 && (
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<div class="dashboard-card-body">
|
||||||
|
<h3 class="dashboard-section-title dashboard-section-title--mb">
|
||||||
|
Expenses by Category
|
||||||
|
</h3>
|
||||||
|
<div class="dashboard-list-spaced">
|
||||||
|
{Object.entries(expensesByCategory)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.map(([category, amount]) => (
|
||||||
|
<div key={category} class="dashboard-row">
|
||||||
|
<div>
|
||||||
|
<p class="dashboard-row-label capitalize">
|
||||||
|
{category}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="dashboard-row-value">
|
||||||
|
${amount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expense History */}
|
||||||
|
<div class="dashboard-card dashboard-card--no-overflow dashboard-card--full-width">
|
||||||
|
<div class="dashboard-card-body">
|
||||||
|
<div class="dashboard-section-header">
|
||||||
|
<h3 class="dashboard-section-title">Expense History</h3>
|
||||||
|
<a
|
||||||
|
href={`/landlord/properties/${property.id}/expenses/add`}
|
||||||
|
class="dashboard-section-link"
|
||||||
|
>
|
||||||
|
Add Expense
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-table-wrap">
|
||||||
|
<table class="dashboard-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="dashboard-table-th">Date</th>
|
||||||
|
<th class="dashboard-table-th">Category</th>
|
||||||
|
<th class="dashboard-table-th">Description</th>
|
||||||
|
<th class="dashboard-table-th">Vendor</th>
|
||||||
|
<th class="dashboard-table-th">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{expenses && expenses.length > 0
|
||||||
|
? (
|
||||||
|
expenses.map((expense) => (
|
||||||
|
<tr key={expense.id}>
|
||||||
|
<td class="dashboard-table-td">
|
||||||
|
{expense.expense_date
|
||||||
|
? new Date(expense.expense_date)
|
||||||
|
.toLocaleDateString()
|
||||||
|
: "N/A"}
|
||||||
|
</td>
|
||||||
|
<td class="dashboard-table-td capitalize">
|
||||||
|
{expense.category || "N/A"}
|
||||||
|
</td>
|
||||||
|
<td class="dashboard-table-td">
|
||||||
|
{expense.description || "-"}
|
||||||
|
</td>
|
||||||
|
<td class="dashboard-table-td">
|
||||||
|
{expense.vendor || "-"}
|
||||||
|
</td>
|
||||||
|
<td class="dashboard-table-td">
|
||||||
|
${expense.amount?.toLocaleString() || "0"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<tr>
|
||||||
|
<td colspan={5} class="dashboard-table-empty">
|
||||||
|
No expenses recorded yet
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Leases */}
|
||||||
|
<div class="dashboard-card dashboard-card--no-overflow dashboard-card--full-width">
|
||||||
|
<div class="dashboard-card-body">
|
||||||
|
<div class="dashboard-section-header">
|
||||||
|
<h3 class="dashboard-section-title">Active Leases</h3>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-list-spaced">
|
||||||
|
{leases && leases.length > 0
|
||||||
|
? (
|
||||||
|
leases
|
||||||
|
.filter((l) => l.lease_status === "active")
|
||||||
|
.map((lease) => (
|
||||||
|
<div key={lease.id} class="dashboard-row">
|
||||||
|
<div>
|
||||||
|
<p class="dashboard-row-label">
|
||||||
|
{lease.title || `Lease #${lease.id.slice(0, 8)}`}
|
||||||
|
</p>
|
||||||
|
<p class="dashboard-row-muted">
|
||||||
|
{lease.start_date
|
||||||
|
? new Date(lease.start_date)
|
||||||
|
.toLocaleDateString()
|
||||||
|
: ""}{" "}
|
||||||
|
-{" "}
|
||||||
|
{lease.end_date
|
||||||
|
? new Date(lease.end_date)
|
||||||
|
.toLocaleDateString()
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="dashboard-row-value">
|
||||||
|
${lease.rent?.toLocaleString() || "0"}/mo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div class="dashboard-empty">
|
||||||
|
<p class="dashboard-empty-text">No active leases</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rent History */}
|
||||||
|
<div class="dashboard-card dashboard-card--no-overflow dashboard-card--full-width">
|
||||||
|
<div class="dashboard-card-body">
|
||||||
|
<div class="dashboard-section-header">
|
||||||
|
<h3 class="dashboard-section-title">Rent History</h3>
|
||||||
|
<a
|
||||||
|
href={`/landlord/properties/${property.id}/rent/change`}
|
||||||
|
class="dashboard-section-link"
|
||||||
|
>
|
||||||
|
Change Rent
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-table-wrap">
|
||||||
|
<table class="dashboard-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="dashboard-table-th">Effective Date</th>
|
||||||
|
<th class="dashboard-table-th">Previous Rent</th>
|
||||||
|
<th class="dashboard-table-th">New Rent</th>
|
||||||
|
<th class="dashboard-table-th">Change</th>
|
||||||
|
<th class="dashboard-table-th">Reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rentHistory && rentHistory.length > 0
|
||||||
|
? (
|
||||||
|
rentHistory.map((entry, index) => {
|
||||||
|
const previousRent = entry.previous_rent || 0;
|
||||||
|
const newRent = entry.monthly_rent || 0;
|
||||||
|
const changeAmount = newRent - previousRent;
|
||||||
|
const isIncrease = changeAmount > 0;
|
||||||
|
const isDecrease = changeAmount < 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={index}>
|
||||||
|
<td class="dashboard-table-td">
|
||||||
|
{entry.effective_date
|
||||||
|
? new Date(entry.effective_date)
|
||||||
|
.toLocaleDateString()
|
||||||
|
: "N/A"}
|
||||||
|
{entry.is_current && (
|
||||||
|
<span class="dashboard-badge dashboard-badge--green" style={{ marginLeft: "8px" }}>
|
||||||
|
Current
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td class="dashboard-table-td">
|
||||||
|
${previousRent.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td class="dashboard-table-td">
|
||||||
|
${newRent.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td class="dashboard-table-td">
|
||||||
|
{previousRent > 0 && (
|
||||||
|
<span
|
||||||
|
class={
|
||||||
|
isIncrease
|
||||||
|
? "text-success"
|
||||||
|
: isDecrease
|
||||||
|
? "text-danger"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isIncrease && "+"}
|
||||||
|
${changeAmount.toLocaleString()}
|
||||||
|
{changeAmount !== 0 && ` (${
|
||||||
|
Math.round(
|
||||||
|
(changeAmount / previousRent) * 100
|
||||||
|
)
|
||||||
|
}%)`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{previousRent === 0 && <span>-</span>}
|
||||||
|
</td>
|
||||||
|
<td class="dashboard-table-td">
|
||||||
|
{entry.reason || "-"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<tr>
|
||||||
|
<td colspan={5} class="dashboard-table-empty">
|
||||||
|
No rent history available
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -1,6 +1,8 @@
|
|||||||
// routes/tenant/dashboard/index.tsx
|
// routes/tenant/dashboard/index.tsx
|
||||||
import { define } from "../../../utils.ts";
|
import { define } from "../../../utils.ts";
|
||||||
|
import { sqlGetUser } from "../../../db/sqlUserRepository.ts";
|
||||||
import { getUserById } from "../../../services/userService.ts";
|
import { getUserById } from "../../../services/userService.ts";
|
||||||
|
import { getOnboardingStatus } from "../../../services/onboardingService.ts";
|
||||||
import { getLeasesByTenant } from "../../../services/leaseService.ts";
|
import { getLeasesByTenant } from "../../../services/leaseService.ts";
|
||||||
import { getMessagesByUser } from "../../../services/messageService.ts";
|
import { getMessagesByUser } from "../../../services/messageService.ts";
|
||||||
import { getMaintenanceRequestsByUser } from "../../../services/maintenanceRequestService.ts";
|
import { getMaintenanceRequestsByUser } from "../../../services/maintenanceRequestService.ts";
|
||||||
@ -45,12 +47,21 @@ async function buildInviteNotices(
|
|||||||
invites: TenantInvite[],
|
invites: TenantInvite[],
|
||||||
leases: Lease[],
|
leases: Lease[],
|
||||||
) {
|
) {
|
||||||
const leasePropertyIds = new Set(
|
// Only hide invites for properties where the lease is final (approved/active).
|
||||||
leases.map((lease) => lease.property_id).filter(Boolean) as string[],
|
// Keep the invite visible while the lease is pending (until "Start Lease" → landlord approval).
|
||||||
|
const finalLeasePropertyIds = new Set(
|
||||||
|
(leases || [])
|
||||||
|
.filter(
|
||||||
|
(lease) =>
|
||||||
|
lease.lease_status === "active" ||
|
||||||
|
lease.approval_status === "approved",
|
||||||
|
)
|
||||||
|
.map((lease) => lease.property_id)
|
||||||
|
.filter(Boolean) as string[],
|
||||||
);
|
);
|
||||||
const pendingInvites = invites.filter((invite) => {
|
const pendingInvites = invites.filter((invite) => {
|
||||||
const propertyId = invite.property_id || "";
|
const propertyId = invite.property_id || "";
|
||||||
return !propertyId || !leasePropertyIds.has(propertyId);
|
return !propertyId || !finalLeasePropertyIds.has(propertyId);
|
||||||
});
|
});
|
||||||
const notices = await Promise.all(pendingInvites.map(async (invite) => {
|
const notices = await Promise.all(pendingInvites.map(async (invite) => {
|
||||||
const property = invite.property_id
|
const property = invite.property_id
|
||||||
@ -71,17 +82,36 @@ export const handler = define.handlers({
|
|||||||
async GET(ctx) {
|
async GET(ctx) {
|
||||||
const { user_id } = ctx.state.data;
|
const { user_id } = ctx.state.data;
|
||||||
|
|
||||||
|
const onboardingStatus = await getOnboardingStatus(user_id);
|
||||||
|
if (!onboardingStatus?.onboarded_tenant) {
|
||||||
|
return new Response("", {
|
||||||
|
status: 303,
|
||||||
|
headers: { Location: "/tenant/onboarding" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await getUserById(user_id);
|
const user = await getUserById(user_id);
|
||||||
const leases = await getLeasesByTenant(user_id);
|
const leases = await getLeasesByTenant(user_id);
|
||||||
const messages = await getMessagesByUser(user_id);
|
const messages = await getMessagesByUser(user_id);
|
||||||
const maintenanceRequests = await getMaintenanceRequestsByUser(user_id);
|
const maintenanceRequests = await getMaintenanceRequestsByUser(user_id);
|
||||||
const recentTransactions = await getPaymentTransactionsByUser(user_id);
|
const recentTransactions = await getPaymentTransactionsByUser(user_id);
|
||||||
const invites = user?.email
|
// Invites are looked up by email; KV user may have no email if OAuth didn't provide it at login.
|
||||||
? await getTenantInvitesByEmail(user.email)
|
const emailForInvites = user?.email?.trim() ||
|
||||||
|
(await sqlGetUser(user_id))?.email?.trim() ||
|
||||||
|
"";
|
||||||
|
const invites = emailForInvites
|
||||||
|
? await getTenantInvitesByEmail(emailForInvites)
|
||||||
: [];
|
: [];
|
||||||
const inviteNotices = await buildInviteNotices(invites, leases || []);
|
const inviteNotices = await buildInviteNotices(invites, leases || []);
|
||||||
|
|
||||||
|
// Fetch property data for the current lease
|
||||||
|
const currentLease = leases?.[0];
|
||||||
|
let currentProperty = null;
|
||||||
|
if (currentLease?.property_id) {
|
||||||
|
currentProperty = await getPropertyById(currentLease.property_id);
|
||||||
|
}
|
||||||
|
|
||||||
return page({
|
return page({
|
||||||
user,
|
user,
|
||||||
leases,
|
leases,
|
||||||
@ -89,6 +119,7 @@ export const handler = define.handlers({
|
|||||||
maintenanceRequests,
|
maintenanceRequests,
|
||||||
recentTransactions,
|
recentTransactions,
|
||||||
inviteNotices,
|
inviteNotices,
|
||||||
|
currentProperty,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading tenant dashboard:", error);
|
console.error("Error loading tenant dashboard:", error);
|
||||||
@ -107,6 +138,7 @@ export default define.page(function TenantDashboard({ data }) {
|
|||||||
maintenanceRequests,
|
maintenanceRequests,
|
||||||
recentTransactions,
|
recentTransactions,
|
||||||
inviteNotices,
|
inviteNotices,
|
||||||
|
currentProperty,
|
||||||
error,
|
error,
|
||||||
} = data as {
|
} = data as {
|
||||||
user: User;
|
user: User;
|
||||||
@ -115,23 +147,25 @@ export default define.page(function TenantDashboard({ data }) {
|
|||||||
maintenanceRequests: MaintenanceRequest[];
|
maintenanceRequests: MaintenanceRequest[];
|
||||||
recentTransactions: PaymentTransaction[];
|
recentTransactions: PaymentTransaction[];
|
||||||
inviteNotices?: InviteNotice[];
|
inviteNotices?: InviteNotice[];
|
||||||
|
currentProperty?: Property | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div class="min-h-screen bg-gray-50 tenant-route flex items-center justify-center">
|
<div class="dashboard-error-page tenant-route">
|
||||||
<div class="text-center">
|
<div class="dashboard-error-inner">
|
||||||
<h1 class="text-2xl font-bold text-gray-900 mb-4">Dashboard Error</h1>
|
<h1 class="dashboard-error-title">Dashboard Error</h1>
|
||||||
<p class="text-gray-600">{error}</p>
|
<p class="dashboard-error-text">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentLease = leases?.[0]; // Assuming tenant has one active lease
|
const currentLease = leases?.[0]; // Assuming tenant has one active lease
|
||||||
const unreadMessageCount = messages?.filter((message) => !message.is_read)
|
const unreadMessageCount = messages?.filter(
|
||||||
.length || 0;
|
(m) => m.recipient_id === user?.id && !m.is_read,
|
||||||
|
).length || 0;
|
||||||
const openRequestCount = maintenanceRequests?.filter((request) =>
|
const openRequestCount = maintenanceRequests?.filter((request) =>
|
||||||
request.status === "open" || request.status === "in_progress"
|
request.status === "open" || request.status === "in_progress"
|
||||||
).length || 0;
|
).length || 0;
|
||||||
@ -140,72 +174,78 @@ export default define.page(function TenantDashboard({ data }) {
|
|||||||
const notificationTotal = unreadMessageCount + openRequestCount + inviteCount;
|
const notificationTotal = unreadMessageCount + openRequestCount + inviteCount;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="min-h-screen bg-gray-50 tenant-route">
|
<div class="dashboard-page tenant-route">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div class="bg-white shadow">
|
<div class="dashboard-header">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="dashboard-header-inner">
|
||||||
<div class="flex justify-between items-center py-6">
|
<div class="dashboard-header-row">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Tenant Portal</h1>
|
<h1 class="dashboard-header-title">Tenant Portal</h1>
|
||||||
<p class="mt-1 text-sm text-gray-500">
|
<p class="dashboard-header-subtitle">
|
||||||
Welcome back, {user?.name}!
|
Welcome back, {user?.name}!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-3">
|
<div class="dashboard-header-actions">
|
||||||
<a
|
<a
|
||||||
href="/tenant/maintenance/new"
|
href="/tenant/maintenance/new"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
class="dashboard-btn dashboard-btn--primary"
|
||||||
>
|
>
|
||||||
Request Maintenance
|
Request Maintenance
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/tenant/messages/new"
|
href="/tenant/messages/new"
|
||||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
class="dashboard-btn dashboard-btn--secondary"
|
||||||
>
|
>
|
||||||
Contact Landlord
|
Contact Landlord
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="/tenant/messages"
|
||||||
|
class={`dashboard-btn ${
|
||||||
|
unreadMessageCount > 0
|
||||||
|
? "dashboard-btn--messages-new"
|
||||||
|
: "dashboard-btn--secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Messages
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="dashboard-content">
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<div class="bg-white shadow rounded-lg mb-8">
|
<div class="dashboard-card dashboard-card--no-overflow dashboard-section-mb">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="dashboard-card-body">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
<div class="dashboard-notification-header">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
<h3 class="dashboard-section-title">
|
||||||
Notification Center
|
Notification Center
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="dashboard-header-subtitle">
|
||||||
Your invites, updates, and action items in one place.
|
Your invites, updates, and action items in one place.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-600">
|
<span class="dashboard-notification-badge">
|
||||||
{notificationTotal} total notifications
|
{notificationTotal} total notifications
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-3">
|
<div class="dashboard-notification-grid">
|
||||||
<div
|
<div
|
||||||
class={`rounded-lg border p-4 ${
|
class={hasInvites
|
||||||
hasInvites
|
? "dashboard-notification-card dashboard-notification-card--highlight"
|
||||||
? "border-amber-200 bg-amber-50"
|
: "dashboard-notification-card"}
|
||||||
: "border-gray-200 bg-white"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="dashboard-notification-card-inner">
|
||||||
<div class="flex items-center gap-3">
|
<div class="dashboard-notification-icon-wrap">
|
||||||
<div
|
<div
|
||||||
class={`flex h-10 w-10 items-center justify-center rounded-full ${
|
class={hasInvites
|
||||||
hasInvites
|
? "dashboard-notification-icon dashboard-notification-icon--highlight"
|
||||||
? "bg-amber-100 text-amber-700"
|
: "dashboard-notification-icon"}
|
||||||
: "bg-gray-100 text-gray-500"
|
|
||||||
} relative`}
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-5 w-5"
|
class="dashboard-notification-icon-svg"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -218,45 +258,45 @@ export default define.page(function TenantDashboard({ data }) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{hasInvites && (
|
{hasInvites && (
|
||||||
<span class="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-amber-500 ring-2 ring-amber-50" />
|
<span
|
||||||
|
class="dashboard-notification-dot"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-900">Invites</p>
|
<p class="dashboard-row-label">Invites</p>
|
||||||
<p class="text-xs text-gray-500">
|
<p class="dashboard-row-sub">
|
||||||
Pending property invitations
|
Pending property invitations
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class={`text-sm font-semibold ${
|
class={hasInvites
|
||||||
hasInvites ? "text-amber-700" : "text-gray-500"
|
? "dashboard-notification-count dashboard-notification-count--highlight"
|
||||||
}`}
|
: "dashboard-notification-count"}
|
||||||
>
|
>
|
||||||
{inviteCount}
|
{inviteCount}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 space-y-3 text-sm text-gray-700">
|
<div class="dashboard-invite-list">
|
||||||
{hasInvites
|
{hasInvites
|
||||||
? (
|
? (
|
||||||
inviteNotices?.slice(0, 2).map((invite) => (
|
inviteNotices?.slice(0, 2).map((invite) => (
|
||||||
<div
|
<div key={invite.id} class="dashboard-invite-item">
|
||||||
key={invite.id}
|
|
||||||
class="flex items-start justify-between gap-3 rounded-md bg-white/80 p-2"
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-gray-900">
|
<p class="dashboard-invite-item-label">
|
||||||
{invite.propertyLabel}
|
{invite.propertyLabel}
|
||||||
</p>
|
</p>
|
||||||
{invite.propertyAddress && (
|
{invite.propertyAddress && (
|
||||||
<p class="text-xs text-gray-500">
|
<p class="dashboard-invite-item-address">
|
||||||
{invite.propertyAddress}
|
{invite.propertyAddress}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href={invite.inviteUrl}
|
href={invite.inviteUrl}
|
||||||
class="text-xs font-semibold text-amber-700 hover:text-amber-600"
|
class="dashboard-invite-link"
|
||||||
>
|
>
|
||||||
Review
|
Review
|
||||||
</a>
|
</a>
|
||||||
@ -264,19 +304,19 @@ export default define.page(function TenantDashboard({ data }) {
|
|||||||
))
|
))
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<p class="text-xs text-gray-500">
|
<p class="dashboard-row-sub">
|
||||||
You have no pending invites.
|
You have no pending invites.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
<div class="dashboard-notification-card">
|
||||||
<div class="flex items-center justify-between">
|
<div class="dashboard-notification-card-inner">
|
||||||
<div class="flex items-center gap-3">
|
<div class="dashboard-notification-icon-wrap">
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 text-blue-700">
|
<div class="dashboard-notification-icon dashboard-notification-icon--blue">
|
||||||
<svg
|
<svg
|
||||||
class="h-5 w-5"
|
class="dashboard-notification-icon-svg"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -290,34 +330,27 @@ export default define.page(function TenantDashboard({ data }) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-900">
|
<p class="dashboard-row-label">Messages</p>
|
||||||
Messages
|
<p class="dashboard-row-sub">
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Unread landlord updates
|
Unread landlord updates
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-semibold text-blue-700">
|
<span class="dashboard-notification-count dashboard-notification-count--blue">
|
||||||
{unreadMessageCount}
|
{unreadMessageCount}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="dashboard-notification-footer">
|
||||||
<a
|
<a href="/tenant/messages">View messages</a>
|
||||||
href="/tenant/messages"
|
|
||||||
class="text-xs font-semibold text-blue-600 hover:text-blue-500"
|
|
||||||
>
|
|
||||||
View messages
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
<div class="dashboard-notification-card">
|
||||||
<div class="flex items-center justify-between">
|
<div class="dashboard-notification-card-inner">
|
||||||
<div class="flex items-center gap-3">
|
<div class="dashboard-notification-icon-wrap">
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
<div class="dashboard-notification-icon dashboard-notification-icon--green">
|
||||||
<svg
|
<svg
|
||||||
class="h-5 w-5"
|
class="dashboard-notification-icon-svg"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -331,22 +364,20 @@ export default define.page(function TenantDashboard({ data }) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-900">
|
<p class="dashboard-row-label">Maintenance</p>
|
||||||
Maintenance
|
<p class="dashboard-row-sub">
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Open or active requests
|
Open or active requests
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-semibold text-emerald-700">
|
<span class="dashboard-notification-count dashboard-notification-count--green">
|
||||||
{openRequestCount}
|
{openRequestCount}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="dashboard-notification-footer">
|
||||||
<a
|
<a
|
||||||
href="/tenant/maintenance"
|
href="/tenant/maintenance"
|
||||||
class="text-xs font-semibold text-emerald-600 hover:text-emerald-500"
|
class="notification-link--green"
|
||||||
>
|
>
|
||||||
View requests
|
View requests
|
||||||
</a>
|
</a>
|
||||||
@ -358,40 +389,40 @@ export default define.page(function TenantDashboard({ data }) {
|
|||||||
|
|
||||||
{/* Property Overview */}
|
{/* Property Overview */}
|
||||||
{currentLease && (
|
{currentLease && (
|
||||||
<div class="bg-white shadow rounded-lg mb-8">
|
<div class="dashboard-card dashboard-section-mb">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="dashboard-card-body">
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
|
<h3 class="dashboard-section-title dashboard-section-title--mb">
|
||||||
Your Rental Property
|
Your Rental Property
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
<div class="dashboard-property-grid">
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Address</dt>
|
<dt class="dashboard-stat-label">Address</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900">
|
<dd class="dashboard-stat-value">
|
||||||
{currentLease.property_address || "Property Address"}
|
{currentProperty
|
||||||
|
? buildPropertyAddress(currentProperty) || "Property Address"
|
||||||
|
: currentLease.property_address || "Property Address"}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">
|
<dt class="dashboard-stat-label">Monthly Rent</dt>
|
||||||
Monthly Rent
|
<dd class="dashboard-stat-value">
|
||||||
</dt>
|
${currentProperty?.monthly_rent?.toLocaleString() ||
|
||||||
<dd class="mt-1 text-sm text-gray-900">
|
currentLease.rent?.toLocaleString() ||
|
||||||
${currentLease.rent?.toLocaleString() || "N/A"}
|
"N/A"}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">
|
<dt class="dashboard-stat-label">Lease End Date</dt>
|
||||||
Lease End Date
|
<dd class="dashboard-stat-value">
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm text-gray-900">
|
|
||||||
{currentLease.end_date
|
{currentLease.end_date
|
||||||
? new Date(currentLease.end_date).toLocaleDateString()
|
? new Date(currentLease.end_date).toLocaleDateString()
|
||||||
: "N/A"}
|
: "N/A"}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Status</dt>
|
<dt class="dashboard-stat-label">Status</dt>
|
||||||
<dd class="mt-1">
|
<dd>
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 capitalize">
|
<span class="dashboard-badge dashboard-badge--green capitalize">
|
||||||
{currentLease.lease_status || "active"}
|
{currentLease.lease_status || "active"}
|
||||||
</span>
|
</span>
|
||||||
</dd>
|
</dd>
|
||||||
@ -402,16 +433,14 @@ export default define.page(function TenantDashboard({ data }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
<div class="dashboard-stats-grid">
|
||||||
<a
|
<a href="/tenant/rent/pay" class="dashboard-quick-action">
|
||||||
href="/tenant/rent/pay"
|
<div class="dashboard-card">
|
||||||
class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-shadow"
|
<div class="dashboard-card-body">
|
||||||
>
|
<div class="dashboard-stat-card">
|
||||||
<div class="p-5">
|
<div class="dashboard-stat-icon-wrap">
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<svg
|
<svg
|
||||||
class="h-6 w-6 text-green-400"
|
class="dashboard-stat-icon dashboard-stat-icon--green"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -424,29 +453,22 @@ export default define.page(function TenantDashboard({ data }) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="dashboard-stat-content">
|
||||||
<dl>
|
<dt class="dashboard-stat-label">Pay Rent</dt>
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">
|
<dd class="dashboard-stat-value">Online Payment</dd>
|
||||||
Pay Rent
|
</div>
|
||||||
</dt>
|
|
||||||
<dd class="text-lg font-medium text-gray-900">
|
|
||||||
Online Payment
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a href="/tenant/maintenance" class="dashboard-quick-action">
|
||||||
href="/tenant/maintenance"
|
<div class="dashboard-card">
|
||||||
class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-shadow"
|
<div class="dashboard-card-body">
|
||||||
>
|
<div class="dashboard-stat-card">
|
||||||
<div class="p-5">
|
<div class="dashboard-stat-icon-wrap">
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<svg
|
<svg
|
||||||
class="h-6 w-6 text-blue-400"
|
class="dashboard-stat-icon dashboard-stat-icon--blue"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -465,29 +487,22 @@ export default define.page(function TenantDashboard({ data }) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="dashboard-stat-content">
|
||||||
<dl>
|
<dt class="dashboard-stat-label">Maintenance</dt>
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">
|
<dd class="dashboard-stat-value">Request Service</dd>
|
||||||
Maintenance
|
</div>
|
||||||
</dt>
|
|
||||||
<dd class="text-lg font-medium text-gray-900">
|
|
||||||
Request Service
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a href="/tenant/documents" class="dashboard-quick-action">
|
||||||
href="/tenant/documents"
|
<div class="dashboard-card">
|
||||||
class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-shadow"
|
<div class="dashboard-card-body">
|
||||||
>
|
<div class="dashboard-stat-card">
|
||||||
<div class="p-5">
|
<div class="dashboard-stat-icon-wrap">
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<svg
|
<svg
|
||||||
class="h-6 w-6 text-purple-400"
|
class="dashboard-stat-icon dashboard-stat-icon--purple"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -500,29 +515,22 @@ export default define.page(function TenantDashboard({ data }) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="dashboard-stat-content">
|
||||||
<dl>
|
<dt class="dashboard-stat-label">Documents</dt>
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">
|
<dd class="dashboard-stat-value">Lease & Forms</dd>
|
||||||
Documents
|
</div>
|
||||||
</dt>
|
|
||||||
<dd class="text-lg font-medium text-gray-900">
|
|
||||||
Lease & Forms
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a href="/tenant/messages" class="dashboard-quick-action">
|
||||||
href="/tenant/messages"
|
<div class="dashboard-card">
|
||||||
class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-shadow"
|
<div class="dashboard-card-body">
|
||||||
>
|
<div class="dashboard-stat-card">
|
||||||
<div class="p-5">
|
<div class="dashboard-stat-icon-wrap">
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<svg
|
<svg
|
||||||
class="h-6 w-6 text-yellow-400"
|
class="dashboard-stat-icon dashboard-stat-icon--yellow"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -535,71 +543,58 @@ export default define.page(function TenantDashboard({ data }) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="dashboard-stat-content">
|
||||||
<dl>
|
<dt class="dashboard-stat-label">Messages</dt>
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">
|
<dd class="dashboard-stat-value">
|
||||||
Messages
|
|
||||||
</dt>
|
|
||||||
<dd class="text-lg font-medium text-gray-900">
|
|
||||||
{unreadMessageCount} Unread
|
{unreadMessageCount} Unread
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
<div class="dashboard-two-col">
|
||||||
{/* Recent Messages */}
|
{/* Recent Messages */}
|
||||||
<div class="bg-white shadow rounded-lg">
|
<div class="dashboard-card dashboard-card--no-overflow">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="dashboard-card-body">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="dashboard-section-header">
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
<h3 class="dashboard-section-title">Recent Messages</h3>
|
||||||
Recent Messages
|
<a href="/tenant/messages" class="dashboard-section-link">
|
||||||
</h3>
|
|
||||||
<a
|
|
||||||
href="/tenant/messages"
|
|
||||||
class="text-sm text-blue-600 hover:text-blue-500"
|
|
||||||
>
|
|
||||||
View all
|
View all
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="dashboard-list-spaced">
|
||||||
{messages && messages.length > 0
|
{messages && messages.length > 0
|
||||||
? (
|
? (
|
||||||
messages.slice(0, 3).map((message) => (
|
messages.slice(0, 3).map((message) => (
|
||||||
<div key={message.id} class="flex items-start space-x-3">
|
<div key={message.id} class="dashboard-message-row">
|
||||||
<div class="flex-shrink-0">
|
<div class="dashboard-message-avatar">
|
||||||
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
<span class="dashboard-message-avatar-text">L</span>
|
||||||
<span class="text-xs font-medium text-blue-700">
|
|
||||||
L
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="dashboard-message-body">
|
||||||
<div class="flex-1 min-w-0">
|
<p class="dashboard-message-from">Landlord</p>
|
||||||
<p class="text-sm font-medium text-gray-900">
|
<p class="dashboard-message-preview">
|
||||||
Landlord
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-500 truncate">
|
|
||||||
{message.subject}
|
{message.subject}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-400">
|
<p class="dashboard-message-meta">
|
||||||
{new Date(message.created_on as number)
|
{new Date(message.created_on as number)
|
||||||
.toLocaleDateString()}
|
.toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!message.is_read && (
|
{!message.is_read && (
|
||||||
<div class="flex-shrink-0">
|
<div
|
||||||
<div class="w-2 h-2 bg-blue-600 rounded-full"></div>
|
class="dashboard-message-dot"
|
||||||
</div>
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<div class="text-center py-4">
|
<div class="dashboard-empty">
|
||||||
<p class="text-sm text-gray-500">No messages yet</p>
|
<p class="dashboard-empty-text">No messages yet</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -607,48 +602,40 @@ export default define.page(function TenantDashboard({ data }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Maintenance Requests */}
|
{/* Maintenance Requests */}
|
||||||
<div class="bg-white shadow rounded-lg">
|
<div class="dashboard-card dashboard-card--no-overflow">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="dashboard-card-body">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="dashboard-section-header">
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
<h3 class="dashboard-section-title">
|
||||||
Maintenance Requests
|
Maintenance Requests
|
||||||
</h3>
|
</h3>
|
||||||
<a
|
<a href="/tenant/maintenance" class="dashboard-section-link">
|
||||||
href="/tenant/maintenance"
|
|
||||||
class="text-sm text-blue-600 hover:text-blue-500"
|
|
||||||
>
|
|
||||||
View all
|
View all
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="dashboard-list-spaced">
|
||||||
{maintenanceRequests && maintenanceRequests.length > 0
|
{maintenanceRequests && maintenanceRequests.length > 0
|
||||||
? (
|
? (
|
||||||
maintenanceRequests.slice(0, 3).map((request) => (
|
maintenanceRequests.slice(0, 3).map((request) => (
|
||||||
<div
|
<div key={request.id} class="dashboard-row">
|
||||||
key={request.id}
|
|
||||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-900">
|
<p class="dashboard-row-label">
|
||||||
{request.request_type}
|
{request.request_type}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-500 capitalize">
|
<p class="dashboard-row-muted capitalize">
|
||||||
Priority: {request.priority}
|
Priority: {request.priority}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-400">
|
<p class="dashboard-row-meta">
|
||||||
{new Date(request.created_on as number)
|
{new Date(request.created_on as number)
|
||||||
.toLocaleDateString()}
|
.toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span
|
<span
|
||||||
class={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
class={request.status === "open"
|
||||||
request.status === "open"
|
? "dashboard-badge dashboard-badge--red-pill"
|
||||||
? "bg-red-100 text-red-800"
|
|
||||||
: request.status === "in_progress"
|
: request.status === "in_progress"
|
||||||
? "bg-yellow-100 text-yellow-800"
|
? "dashboard-badge dashboard-badge--yellow"
|
||||||
: "bg-green-100 text-green-800"
|
: "dashboard-badge dashboard-badge--green"}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{request.status?.replace("_", " ")}
|
{request.status?.replace("_", " ")}
|
||||||
</span>
|
</span>
|
||||||
@ -657,13 +644,13 @@ export default define.page(function TenantDashboard({ data }) {
|
|||||||
))
|
))
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<div class="text-center py-4">
|
<div class="dashboard-empty">
|
||||||
<p class="text-sm text-gray-500">
|
<p class="dashboard-empty-text">
|
||||||
No maintenance requests
|
No maintenance requests
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/tenant/maintenance/new"
|
href="/tenant/maintenance/new"
|
||||||
class="text-sm text-blue-600 hover:text-blue-500"
|
class="dashboard-empty-link"
|
||||||
>
|
>
|
||||||
Submit a request
|
Submit a request
|
||||||
</a>
|
</a>
|
||||||
@ -674,62 +661,48 @@ export default define.page(function TenantDashboard({ data }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Payments */}
|
{/* Recent Payments */}
|
||||||
<div class="bg-white shadow rounded-lg lg:col-span-2">
|
<div class="dashboard-card dashboard-card--full-width">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="dashboard-card-body">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="dashboard-section-header">
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
<h3 class="dashboard-section-title">Recent Payments</h3>
|
||||||
Recent Payments
|
<a href="/tenant/payments" class="dashboard-section-link">
|
||||||
</h3>
|
|
||||||
<a
|
|
||||||
href="/tenant/payments"
|
|
||||||
class="text-sm text-blue-600 hover:text-blue-500"
|
|
||||||
>
|
|
||||||
View all
|
View all
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-hidden">
|
<div class="dashboard-table-wrap">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="dashboard-table">
|
||||||
<thead class="bg-gray-50">
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th class="dashboard-table-th">Date</th>
|
||||||
Date
|
<th class="dashboard-table-th">Description</th>
|
||||||
</th>
|
<th class="dashboard-table-th">Amount</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th class="dashboard-table-th">Status</th>
|
||||||
Description
|
|
||||||
</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Amount
|
|
||||||
</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<tbody>
|
||||||
{recentTransactions && recentTransactions.length > 0
|
{recentTransactions && recentTransactions.length > 0
|
||||||
? (
|
? (
|
||||||
recentTransactions.map((transaction) => (
|
recentTransactions.map((transaction) => (
|
||||||
<tr key={transaction.id}>
|
<tr key={transaction.id}>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td class="dashboard-table-td">
|
||||||
{new Date(transaction.created_on as number)
|
{new Date(transaction.created_on as number)
|
||||||
.toLocaleDateString()}
|
.toLocaleDateString()}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td class="dashboard-table-td">
|
||||||
{transaction.payment_method}
|
{transaction.payment_method}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td class="dashboard-table-td">
|
||||||
${transaction.amount?.toLocaleString()}
|
${transaction.amount?.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="dashboard-table-td">
|
||||||
<span
|
<span
|
||||||
class={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
class={transaction.transaction_status ===
|
||||||
transaction.transaction_status === "completed"
|
"completed"
|
||||||
? "bg-green-100 text-green-800"
|
? "dashboard-badge dashboard-badge--green"
|
||||||
: transaction.transaction_status ===
|
: transaction.transaction_status ===
|
||||||
"pending"
|
"pending"
|
||||||
? "bg-yellow-100 text-yellow-800"
|
? "dashboard-badge dashboard-badge--yellow"
|
||||||
: "bg-red-100 text-red-800"
|
: "dashboard-badge dashboard-badge--red"}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{transaction.transaction_status}
|
{transaction.transaction_status}
|
||||||
</span>
|
</span>
|
||||||
@ -739,10 +712,7 @@ export default define.page(function TenantDashboard({ data }) {
|
|||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td colspan={4} class="dashboard-table-empty">
|
||||||
colspan={4}
|
|
||||||
class="px-6 py-4 text-center text-sm text-gray-500"
|
|
||||||
>
|
|
||||||
No payment history yet
|
No payment history yet
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
} from "../../../services/tenantInviteService.ts";
|
} from "../../../services/tenantInviteService.ts";
|
||||||
import { getUserById } from "../../../services/userService.ts";
|
import { getUserById } from "../../../services/userService.ts";
|
||||||
import { getLeaseById } from "../../../services/leaseService.ts";
|
import { getLeaseById } from "../../../services/leaseService.ts";
|
||||||
|
import { createMessage } from "../../../services/messageService.ts";
|
||||||
import LoginPurposeButton from "../../../islands/LoginPurposeButton.tsx";
|
import LoginPurposeButton from "../../../islands/LoginPurposeButton.tsx";
|
||||||
import type {
|
import type {
|
||||||
Lease,
|
Lease,
|
||||||
@ -132,9 +133,16 @@ export const handler = define.handlers({
|
|||||||
|
|
||||||
await startGuestOnboarding(userId, "tenant");
|
await startGuestOnboarding(userId, "tenant");
|
||||||
|
|
||||||
const response = await fetch("/api/lease/request-approval", {
|
const cookieHeader = ctx.req.headers.get("Cookie") ?? "";
|
||||||
|
const requestUrl = new URL(ctx.req.url);
|
||||||
|
const apiUrl = `${requestUrl.origin}/api/lease/request-approval`;
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cookie": cookieHeader,
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
invite_id: invite.id,
|
invite_id: invite.id,
|
||||||
property_id: invite.property_id,
|
property_id: invite.property_id,
|
||||||
@ -150,6 +158,23 @@ export const handler = define.handlers({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const responseData = await response.json().catch(() => ({}));
|
||||||
|
const lease = responseData?.lease;
|
||||||
|
const leaseId = lease?.id ?? null;
|
||||||
|
|
||||||
|
if (invite.landlord_id && leaseId) {
|
||||||
|
await createMessage(
|
||||||
|
leaseId,
|
||||||
|
invite.landlord_id,
|
||||||
|
userId,
|
||||||
|
"notice",
|
||||||
|
"Lease documents – next steps",
|
||||||
|
"The landlord was notified of your lease request. A follow-up message will contain more information about your lease documents.",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await markRoleOnboarded(userId, "tenant");
|
await markRoleOnboarded(userId, "tenant");
|
||||||
|
|
||||||
return new Response("", {
|
return new Response("", {
|
||||||
@ -211,6 +236,8 @@ export default define.page(function TenantInvitePreview({ data }) {
|
|||||||
const isApproved = approvalStatus === "approved";
|
const isApproved = approvalStatus === "approved";
|
||||||
const isRejected = approvalStatus === "rejected";
|
const isRejected = approvalStatus === "rejected";
|
||||||
const hasLease = Boolean(lease);
|
const hasLease = Boolean(lease);
|
||||||
|
// Allow "Start Lease" when no lease or when previous request was rejected (re-invite same property)
|
||||||
|
const canStartLease = !hasLease || isRejected;
|
||||||
|
|
||||||
if (!invite || !property) {
|
if (!invite || !property) {
|
||||||
return (
|
return (
|
||||||
@ -254,16 +281,22 @@ export default define.page(function TenantInvitePreview({ data }) {
|
|||||||
const isLoggedIn = Boolean(user?.id);
|
const isLoggedIn = Boolean(user?.id);
|
||||||
|
|
||||||
const approvalSteps = [
|
const approvalSteps = [
|
||||||
{ label: "Request Lease", completed: hasLease },
|
{ label: "Request Lease", completed: hasLease && !isRejected },
|
||||||
{ label: "Landlord Review", completed: isApproved },
|
{ label: "Landlord Review", completed: isApproved },
|
||||||
{ label: "Lease Active", completed: isApproved },
|
{ label: "Lease Active", completed: isApproved },
|
||||||
];
|
];
|
||||||
|
|
||||||
const getStepStatus = (stepIndex: number) => {
|
const getStepStatus = (stepIndex: number) => {
|
||||||
if (!hasLease) return stepIndex === 0 ? "current" : "pending";
|
if (!hasLease) return stepIndex === 0 ? "current" : "pending";
|
||||||
|
if (isRejected) {
|
||||||
|
return stepIndex === 0
|
||||||
|
? "current"
|
||||||
|
: stepIndex === 1
|
||||||
|
? "error"
|
||||||
|
: "pending";
|
||||||
|
}
|
||||||
if (isPending) return stepIndex <= 1 ? "current" : "pending";
|
if (isPending) return stepIndex <= 1 ? "current" : "pending";
|
||||||
if (isApproved) return stepIndex <= 2 ? "current" : "completed";
|
if (isApproved) return stepIndex <= 2 ? "current" : "completed";
|
||||||
if (isRejected) return stepIndex === 1 ? "error" : "completed";
|
|
||||||
return "pending";
|
return "pending";
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -502,6 +535,20 @@ export default define.page(function TenantInvitePreview({ data }) {
|
|||||||
lease request. You will receive an email once a
|
lease request. You will receive an email once a
|
||||||
decision is made.
|
decision is made.
|
||||||
</p>
|
</p>
|
||||||
|
<p class="mt-3 text-amber-700 font-medium">
|
||||||
|
The landlord will send you the lease documents.
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-amber-600">
|
||||||
|
A confirmation has been added to your{" "}
|
||||||
|
<a
|
||||||
|
href="/tenant/messages"
|
||||||
|
class="underline font-medium hover:text-amber-800"
|
||||||
|
>
|
||||||
|
Messages
|
||||||
|
</a>{" "}
|
||||||
|
– the landlord was notified and a follow-up message
|
||||||
|
will contain more information.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isApproved && (
|
{isApproved && (
|
||||||
@ -567,7 +614,7 @@ export default define.page(function TenantInvitePreview({ data }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!hasLease && (
|
{canStartLease && (
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
Click "Start Lease" to request landlord approval for this
|
Click "Start Lease" to request landlord approval for this
|
||||||
@ -582,7 +629,7 @@ export default define.page(function TenantInvitePreview({ data }) {
|
|||||||
What happens next
|
What happens next
|
||||||
</h2>
|
</h2>
|
||||||
<ul class="mt-4 space-y-2 text-sm text-gray-500">
|
<ul class="mt-4 space-y-2 text-sm text-gray-500">
|
||||||
{!hasLease && (
|
{canStartLease && (
|
||||||
<>
|
<>
|
||||||
<li>- Click "Start Lease" to request landlord approval.</li>
|
<li>- Click "Start Lease" to request landlord approval.</li>
|
||||||
<li>- Your landlord will review your request.</li>
|
<li>- Your landlord will review your request.</li>
|
||||||
@ -592,10 +639,15 @@ export default define.page(function TenantInvitePreview({ data }) {
|
|||||||
{isPending && (
|
{isPending && (
|
||||||
<>
|
<>
|
||||||
<li>- Your landlord has been notified of your request.</li>
|
<li>- Your landlord has been notified of your request.</li>
|
||||||
|
<li>- The landlord will send you the lease documents.</li>
|
||||||
<li>- Wait for landlord to review and approve/reject.</li>
|
<li>- Wait for landlord to review and approve/reject.</li>
|
||||||
<li>
|
<li>
|
||||||
- You will receive an email notification with a decision.
|
- You will receive an email notification with a decision.
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
- Check your Messages for a confirmation and follow-up
|
||||||
|
details.
|
||||||
|
</li>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isApproved && (
|
{isApproved && (
|
||||||
@ -609,7 +661,18 @@ export default define.page(function TenantInvitePreview({ data }) {
|
|||||||
<>
|
<>
|
||||||
<li>- Your lease request was declined.</li>
|
<li>- Your lease request was declined.</li>
|
||||||
<li>- Contact your landlord if you have questions.</li>
|
<li>- Contact your landlord if you have questions.</li>
|
||||||
<li>- You can request a new invite from your landlord.</li>
|
{canStartLease
|
||||||
|
? (
|
||||||
|
<li>
|
||||||
|
- You can submit a new request using the Start Lease
|
||||||
|
button above.
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<li>
|
||||||
|
- You can request a new invite from your landlord.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
@ -624,18 +687,18 @@ export default define.page(function TenantInvitePreview({ data }) {
|
|||||||
Ready to proceed?
|
Ready to proceed?
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
{!hasLease &&
|
{canStartLease &&
|
||||||
"Start your lease request, decline the invite, or come back later."}
|
"Start your lease request, decline the invite, or come back later."}
|
||||||
{isPending &&
|
{isPending &&
|
||||||
"Your request is pending landlord review. Check back here for updates."}
|
"Your request is pending landlord review. Check back here for updates."}
|
||||||
{isApproved &&
|
{isApproved &&
|
||||||
"Your lease is approved! Continue to your tenant portal."}
|
"Your lease is approved! Continue to your tenant portal."}
|
||||||
{isRejected &&
|
{isRejected && !canStartLease &&
|
||||||
"Your lease request was declined. Contact your landlord for more information."}
|
"Your lease request was declined. Contact your landlord for more information."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
{!hasLease && isLoggedIn && (
|
{canStartLease && isLoggedIn && (
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<input type="hidden" name="intent" value="start" />
|
<input type="hidden" name="intent" value="start" />
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
// routes/tenant/messages/[id]/index.tsx
|
// routes/tenant/messages/[id]/index.tsx
|
||||||
import { define } from "../../../../utils.ts";
|
import { define } from "../../../../utils.ts";
|
||||||
import { page } from "fresh";
|
import { page } from "fresh";
|
||||||
import { getMessageThread } from "../../../../services/messageService.ts";
|
import {
|
||||||
|
getMessageThread,
|
||||||
|
markThreadAsRead,
|
||||||
|
} from "../../../../services/messageService.ts";
|
||||||
import { Message } from "../../../../db/interfaces.ts";
|
import { Message } from "../../../../db/interfaces.ts";
|
||||||
import { AlertMessage } from "../../maintenance/new/components/AlertMessage.tsx";
|
import { AlertMessage } from "../../maintenance/new/components/AlertMessage.tsx";
|
||||||
|
|
||||||
@ -29,9 +32,10 @@ export const handler = define.handlers({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// The root message is the first one in the thread array (due to the service's logic)
|
|
||||||
const rootMessageId = thread[0].parent_message_id || thread[0].id;
|
const rootMessageId = thread[0].parent_message_id || thread[0].id;
|
||||||
|
|
||||||
|
await markThreadAsRead(user_id, rootMessageId);
|
||||||
|
|
||||||
return page<MessageThreadPageData>({
|
return page<MessageThreadPageData>({
|
||||||
thread,
|
thread,
|
||||||
currentUserId: user_id,
|
currentUserId: user_id,
|
||||||
@ -117,12 +121,20 @@ export default define.page(function MessageThreadPage({ data }) {
|
|||||||
<h1 class="text-3xl font-bold text-gray-900">Message Thread</h1>
|
<h1 class="text-3xl font-bold text-gray-900">Message Thread</h1>
|
||||||
<p class="mt-1 text-sm text-gray-500">Subject: {subject}</p>
|
<p class="mt-1 text-sm text-gray-500">Subject: {subject}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
<a
|
<a
|
||||||
href={`/tenant/messages/new?reply_to=${rootMessageId}`}
|
href="/tenant/messages"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 shadow-sm"
|
||||||
>
|
>
|
||||||
Reply
|
All messages
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="/tenant/dashboard"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 shadow-sm"
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,13 +3,12 @@ import { page } from "fresh";
|
|||||||
import { define } from "../../../utils.ts";
|
import { define } from "../../../utils.ts";
|
||||||
import { SessionCookie } from "../../auth/callback.ts";
|
import { SessionCookie } from "../../auth/callback.ts";
|
||||||
import {
|
import {
|
||||||
DashboardMessage,
|
|
||||||
deleteMessagesForUser,
|
deleteMessagesForUser,
|
||||||
getMessagesForUser,
|
getThreadsForUser,
|
||||||
markMessagesAsRead,
|
markMessagesAsRead,
|
||||||
MessagePage,
|
ThreadPage,
|
||||||
|
ThreadSummary,
|
||||||
} from "../../../services/messageService.ts";
|
} from "../../../services/messageService.ts";
|
||||||
import LandlordMessageSelection from "../../../islands/LandlordMessageSelection.tsx";
|
|
||||||
import LandlordMessageThreads from "../../../islands/LandlordMessageThreads.tsx";
|
import LandlordMessageThreads from "../../../islands/LandlordMessageThreads.tsx";
|
||||||
import TenantMessagesFilters from "../../../islands/TenantMessagesFilters.tsx";
|
import TenantMessagesFilters from "../../../islands/TenantMessagesFilters.tsx";
|
||||||
|
|
||||||
@ -29,7 +28,7 @@ interface Filters {
|
|||||||
|
|
||||||
interface MessagesPageData {
|
interface MessagesPageData {
|
||||||
stateData: SessionCookie;
|
stateData: SessionCookie;
|
||||||
messagePage: MessagePage;
|
threadPage: ThreadPage;
|
||||||
filters: Filters;
|
filters: Filters;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
@ -109,7 +108,7 @@ export const handler = define.handlers({
|
|||||||
pageSize,
|
pageSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
const messagePage = await getMessagesForUser(user_id, {
|
const threadPage = await getThreadsForUser(user_id, {
|
||||||
page: pageNumber,
|
page: pageNumber,
|
||||||
pageSize,
|
pageSize,
|
||||||
sort: filters.sort,
|
sort: filters.sort,
|
||||||
@ -121,19 +120,19 @@ export const handler = define.handlers({
|
|||||||
|
|
||||||
return page<MessagesPageData>({
|
return page<MessagesPageData>({
|
||||||
stateData,
|
stateData,
|
||||||
messagePage,
|
threadPage,
|
||||||
filters,
|
filters,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading messages:", error);
|
console.error("Error loading messages:", error);
|
||||||
return page<MessagesPageData>({
|
return page<MessagesPageData>({
|
||||||
stateData: ctx.state.data as SessionCookie,
|
stateData: ctx.state.data as SessionCookie,
|
||||||
messagePage: {
|
threadPage: {
|
||||||
messages: [],
|
threads: [],
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
total: 0,
|
total: 0,
|
||||||
totalMessages: 0,
|
totalThreads: 0,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
unread: 0,
|
unread: 0,
|
||||||
},
|
},
|
||||||
@ -184,62 +183,59 @@ export const handler = define.handlers({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default define.page(function TenantMessages({ data }) {
|
export default define.page(function TenantMessages({ data }) {
|
||||||
const { stateData, messagePage, filters, error } = data as MessagesPageData;
|
const { stateData, threadPage, filters, error } = data as MessagesPageData;
|
||||||
const { messages, page, total, totalPages } = messagePage;
|
const { threads, page, totalPages } = threadPage;
|
||||||
const totalMessages = messagePage.totalMessages ?? total;
|
const totalThreads = threadPage.totalThreads ?? threadPage.total;
|
||||||
const unread = messagePage.unread ?? 0;
|
const unread = threadPage.unread ?? 0;
|
||||||
|
|
||||||
const MessageRow = ({ message }: { message: DashboardMessage }) => {
|
const ThreadRow = ({ thread }: { thread: ThreadSummary }) => {
|
||||||
const isUnread = !message.is_read;
|
const msg = thread.latestMessage;
|
||||||
const senderName = message.sender_id === stateData.user_id
|
const senderName = msg.sender_id === stateData.user_id
|
||||||
? "You"
|
? "You"
|
||||||
: "Landlord";
|
: (msg.sender_name ?? "Landlord");
|
||||||
const isReply = !!message.parent_message_id;
|
const preview = (msg.content_encrypted || msg.content || "").substring(
|
||||||
|
0,
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={message.id}
|
|
||||||
class={`p-4 border-b border-gray-200 transition-colors ${
|
class={`p-4 border-b border-gray-200 transition-colors ${
|
||||||
isUnread
|
thread.hasUnread
|
||||||
? "bg-blue-50 hover:bg-blue-100"
|
? "bg-blue-50 hover:bg-blue-100"
|
||||||
: "bg-white hover:bg-gray-50"
|
: "bg-white hover:bg-gray-50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="message_id"
|
|
||||||
value={message.id}
|
|
||||||
class="h-4 w-4 text-blue-600 border-gray-300 rounded message-checkbox"
|
|
||||||
data-message-id={message.id}
|
|
||||||
/>
|
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="text-sm font-medium text-gray-900">
|
<div class="text-sm font-medium text-gray-900 flex items-center gap-2 flex-wrap">
|
||||||
{isReply && (
|
{thread.hasUnread && (
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 mr-2">
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-blue-600 text-white">
|
||||||
Reply
|
New!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{thread.subject}
|
||||||
|
{thread.messageCount > 1 && (
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
({thread.messageCount} messages)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{message.subject || "No Subject"}
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-500 truncate">
|
<p class="text-sm text-gray-500 truncate mt-0.5">
|
||||||
<span class="font-semibold text-gray-700">{senderName}:</span>
|
<span class="font-semibold text-gray-700">{senderName}:</span>
|
||||||
{" "}
|
{" "}
|
||||||
{(message.content_encrypted || message.content)?.substring(
|
{preview || "No content preview"}
|
||||||
0,
|
{preview.length >= 100 ? "..." : ""}
|
||||||
100,
|
|
||||||
) || "No content preview"}
|
|
||||||
...
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="flex items-center space-x-4 shrink-0">
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<div class="text-sm text-gray-500">
|
<div class="text-sm text-gray-500">
|
||||||
{new Date(message.created_on!).toLocaleDateString()}
|
{msg.created_on
|
||||||
|
? new Date(msg.created_on).toLocaleDateString()
|
||||||
|
: "—"}
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href={`/tenant/messages/${message.id}`}
|
href={`/tenant/messages/${thread.rootMessageId}`}
|
||||||
class="text-sm text-blue-600 hover:text-blue-500"
|
class="text-sm text-blue-600 hover:text-blue-500"
|
||||||
>
|
>
|
||||||
View Thread
|
View Thread
|
||||||
@ -269,6 +265,12 @@ export default define.page(function TenantMessages({ data }) {
|
|||||||
>
|
>
|
||||||
New Message
|
New Message
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="/tenant/dashboard"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 shadow-sm"
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -301,27 +303,26 @@ export default define.page(function TenantMessages({ data }) {
|
|||||||
</span>{" "}
|
</span>{" "}
|
||||||
to{" "}
|
to{" "}
|
||||||
<span class="font-medium">
|
<span class="font-medium">
|
||||||
{Math.min(page * filters.pageSize, totalMessages)}
|
{Math.min(page * filters.pageSize, totalThreads)}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
of{" "}
|
of{" "}
|
||||||
<span class="font-medium">
|
<span class="font-medium">
|
||||||
{totalMessages}
|
{totalThreads}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
results
|
threads
|
||||||
{unread > 0 && (
|
{unread > 0 && (
|
||||||
<span class="ml-2 text-blue-600 font-semibold">
|
<span class="ml-2 text-blue-600 font-semibold">
|
||||||
({unread} unread)
|
({unread} unread)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<LandlordMessageSelection />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{messages.length > 0
|
{threads.length > 0
|
||||||
? (
|
? (
|
||||||
<ul class="divide-y divide-gray-200">
|
<ul class="divide-y divide-gray-200">
|
||||||
{messages.map((message) => (
|
{threads.map((thread) => (
|
||||||
<MessageRow key={message.id} message={message} />
|
<ThreadRow key={thread.rootMessageId} thread={thread} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import {
|
|||||||
createMessage,
|
createMessage,
|
||||||
getMessageById,
|
getMessageById,
|
||||||
} from "../../../../services/messageService.ts";
|
} from "../../../../services/messageService.ts";
|
||||||
|
import { getTenantInvitesByEmail } from "../../../../services/tenantInviteService.ts";
|
||||||
|
import { getUserById } from "../../../../services/userService.ts";
|
||||||
import { uploadToLocalBlobStorage } from "../../../../utils/blobStorage.ts";
|
import { uploadToLocalBlobStorage } from "../../../../utils/blobStorage.ts";
|
||||||
import { Lease, Message } from "../../../../db/interfaces.ts";
|
import { Lease, Message } from "../../../../db/interfaces.ts";
|
||||||
import { FormField } from "../../maintenance/new/components/FormField.tsx";
|
import { FormField } from "../../maintenance/new/components/FormField.tsx";
|
||||||
@ -29,17 +31,6 @@ export const handler = define.handlers({
|
|||||||
const url = new URL(ctx.req.url);
|
const url = new URL(ctx.req.url);
|
||||||
const replyToId = url.searchParams.get("reply_to");
|
const replyToId = url.searchParams.get("reply_to");
|
||||||
|
|
||||||
const leases = await getLeasesByTenant(user_id);
|
|
||||||
const currentLease = leases?.[0];
|
|
||||||
const landlordId = currentLease?.owner_id;
|
|
||||||
|
|
||||||
if (!landlordId) {
|
|
||||||
return page<NewMessagePageData>({
|
|
||||||
error: "Could not find an active lease or landlord ID.",
|
|
||||||
initialSubject: "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let replyToMessage: Message | null = null;
|
let replyToMessage: Message | null = null;
|
||||||
let initialSubject = "";
|
let initialSubject = "";
|
||||||
|
|
||||||
@ -47,9 +38,8 @@ export const handler = define.handlers({
|
|||||||
const fetchedMessage = await getMessageById(replyToId);
|
const fetchedMessage = await getMessageById(replyToId);
|
||||||
if (fetchedMessage) {
|
if (fetchedMessage) {
|
||||||
replyToMessage = fetchedMessage;
|
replyToMessage = fetchedMessage;
|
||||||
// Use the root message ID for threading if it's a reply
|
|
||||||
const rootId = replyToMessage.parent_message_id || replyToMessage.id;
|
const rootId = replyToMessage.parent_message_id || replyToMessage.id;
|
||||||
replyToMessage.id = rootId; // Overwrite ID to ensure reply goes to the root
|
replyToMessage.id = rootId;
|
||||||
|
|
||||||
const originalSubject = replyToMessage.subject || "No Subject";
|
const originalSubject = replyToMessage.subject || "No Subject";
|
||||||
initialSubject = originalSubject.startsWith("Re:")
|
initialSubject = originalSubject.startsWith("Re:")
|
||||||
@ -58,8 +48,34 @@ export const handler = define.handlers({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const leases = await getLeasesByTenant(user_id);
|
||||||
|
const currentLease = leases?.[0];
|
||||||
|
let landlordId = currentLease?.owner_id ?? null;
|
||||||
|
|
||||||
|
if (!landlordId && replyToMessage) {
|
||||||
|
const fromReply = replyToMessage.sender_id === user_id
|
||||||
|
? replyToMessage.recipient_id
|
||||||
|
: replyToMessage.sender_id;
|
||||||
|
landlordId = fromReply ?? null;
|
||||||
|
}
|
||||||
|
if (!landlordId) {
|
||||||
|
const user = await getUserById(user_id);
|
||||||
|
const invites = user?.email
|
||||||
|
? await getTenantInvitesByEmail(user.email)
|
||||||
|
: [];
|
||||||
|
landlordId = invites?.[0]?.landlord_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!landlordId) {
|
||||||
return page<NewMessagePageData>({
|
return page<NewMessagePageData>({
|
||||||
currentLease,
|
error:
|
||||||
|
"We couldn't find a recipient for your message. If you have a pending invite, use it to message your landlord.",
|
||||||
|
initialSubject: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return page<NewMessagePageData>({
|
||||||
|
currentLease: currentLease ?? undefined,
|
||||||
landlordId,
|
landlordId,
|
||||||
replyToMessage,
|
replyToMessage,
|
||||||
initialSubject,
|
initialSubject,
|
||||||
@ -85,9 +101,10 @@ export const handler = define.handlers({
|
|||||||
const parent_message_id = formData.get("parent_message_id") as string ||
|
const parent_message_id = formData.get("parent_message_id") as string ||
|
||||||
null;
|
null;
|
||||||
|
|
||||||
if (!lease_id || !recipient_id || !subject || !content) {
|
if (!recipient_id || !subject || !content) {
|
||||||
return new Response("Missing required fields", { status: 400 });
|
return new Response("Missing required fields", { status: 400 });
|
||||||
}
|
}
|
||||||
|
const leaseIdForMessage = lease_id?.trim() || null;
|
||||||
|
|
||||||
const attachments: { url: string; name: string; type: string }[] = [];
|
const attachments: { url: string; name: string; type: string }[] = [];
|
||||||
const files = formData.getAll("attachments") as File[];
|
const files = formData.getAll("attachments") as File[];
|
||||||
@ -114,7 +131,7 @@ export const handler = define.handlers({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await createMessage(
|
await createMessage(
|
||||||
lease_id,
|
leaseIdForMessage,
|
||||||
user_id,
|
user_id,
|
||||||
recipient_id,
|
recipient_id,
|
||||||
"general", // Default message type for tenant-initiated messages
|
"general", // Default message type for tenant-initiated messages
|
||||||
@ -176,13 +193,13 @@ export default define.page(function NewTenantMessage({ data }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentLease || !landlordId) {
|
if (!landlordId) {
|
||||||
return (
|
return (
|
||||||
<div class="min-h-screen bg-gray-50 tenant-route p-8">
|
<div class="min-h-screen bg-gray-50 tenant-route p-8">
|
||||||
<AlertMessage
|
<AlertMessage
|
||||||
type="error"
|
type="error"
|
||||||
message={error ||
|
message={error ||
|
||||||
"You must have an active lease to send a message. Please contact support."}
|
"We couldn't find a recipient for your message. If you have a pending invite, use it to message your landlord."}
|
||||||
/>
|
/>
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<a
|
<a
|
||||||
@ -214,11 +231,13 @@ export default define.page(function NewTenantMessage({ data }) {
|
|||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<form method="POST" encType="multipart/form-data">
|
<form method="POST" encType="multipart/form-data">
|
||||||
{/* Hidden Fields */}
|
{/* Hidden Fields */}
|
||||||
|
{currentLease && (
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name="lease_id"
|
name="lease_id"
|
||||||
value={currentLease.id}
|
value={currentLease.id}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name="recipient_id"
|
name="recipient_id"
|
||||||
|
|||||||
174
routes/tenant/onboarding/index.tsx
Normal file
174
routes/tenant/onboarding/index.tsx
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
// routes/tenant/onboarding/index.tsx
|
||||||
|
import { define } from "../../../utils.ts";
|
||||||
|
import { page } from "fresh";
|
||||||
|
import type { SessionCookie } from "../../auth/callback.ts";
|
||||||
|
import { getUserById, updateUser } from "../../../services/userService.ts";
|
||||||
|
import {
|
||||||
|
getOnboardingStatus,
|
||||||
|
markRoleOnboarded,
|
||||||
|
} from "../../../services/onboardingService.ts";
|
||||||
|
import { User } from "../../../db/interfaces.ts";
|
||||||
|
import { createPublicId } from "../../../helpers.ts";
|
||||||
|
|
||||||
|
export const handler = define.handlers({
|
||||||
|
async GET(ctx) {
|
||||||
|
const stateData = ctx.state.data as SessionCookie;
|
||||||
|
const { user_id } = stateData;
|
||||||
|
|
||||||
|
const publicId = stateData.user_id
|
||||||
|
? await createPublicId(stateData.user_id)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const status = await getOnboardingStatus(user_id);
|
||||||
|
if (status?.onboarded_tenant) {
|
||||||
|
return new Response("", {
|
||||||
|
status: 303,
|
||||||
|
headers: { Location: "/tenant/dashboard" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserById(user_id);
|
||||||
|
|
||||||
|
return page({ stateData, user: user ? { ...user, id: publicId } : null });
|
||||||
|
},
|
||||||
|
|
||||||
|
async POST(ctx) {
|
||||||
|
const stateData = ctx.state.data as SessionCookie;
|
||||||
|
const { user_id } = stateData;
|
||||||
|
|
||||||
|
const publicId = stateData.user_id
|
||||||
|
? await createPublicId(stateData.user_id)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const status = await getOnboardingStatus(user_id);
|
||||||
|
if (status?.onboarded_tenant) {
|
||||||
|
return new Response("", {
|
||||||
|
status: 303,
|
||||||
|
headers: { Location: "/tenant/dashboard" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await ctx.req.formData();
|
||||||
|
|
||||||
|
const name = (formData.get("name") || "").toString().trim();
|
||||||
|
const email = (formData.get("email") || "").toString().trim();
|
||||||
|
const phone = (formData.get("phone") || "").toString().trim();
|
||||||
|
|
||||||
|
if (!name || !email || !phone) {
|
||||||
|
const user = await getUserById(user_id);
|
||||||
|
return page({
|
||||||
|
stateData,
|
||||||
|
user: user ? { ...user, id: publicId } : null,
|
||||||
|
error: "Name, email, and phone are required.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateUser(user_id, {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
user_type: "tenant",
|
||||||
|
onboarded_tenant: true,
|
||||||
|
} as Partial<User>);
|
||||||
|
|
||||||
|
await markRoleOnboarded(user_id, "tenant");
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 303,
|
||||||
|
headers: { Location: "/tenant/dashboard" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default define.page(function TenantOnboarding({ data }) {
|
||||||
|
const { stateData: _stateData, user, error } = data as {
|
||||||
|
stateData: SessionCookie;
|
||||||
|
user: User | null;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8 tenant-route">
|
||||||
|
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
|
Complete Your Tenant Profile
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-center text-sm text-gray-600">
|
||||||
|
We just need a few details to get you started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||||
|
{error && (
|
||||||
|
<div class="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form method="POST" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="name"
|
||||||
|
class="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Full Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={user?.name || ""}
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="email"
|
||||||
|
class="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Email Address *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={user?.email || ""}
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="phone"
|
||||||
|
class="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Phone Number *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
value={user?.phone || ""}
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-6">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
Continue to Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
25
scripts/clear-messages-leases-then-fix-fk.sql
Normal file
25
scripts/clear-messages-leases-then-fix-fk.sql
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
-- Clear messages and leases, then update messages.lease_id FK to ON DELETE SET NULL.
|
||||||
|
-- Run with: psql "<connection string>" -f scripts/clear-messages-leases-then-fix-fk.sql
|
||||||
|
-- WARNING: Permanently deletes all rows in messages and leases (and lease_documents).
|
||||||
|
-- Unlinks lease_id in tenant_invites, payment_transactions, and reminders.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1. Clear messages (references leases)
|
||||||
|
DELETE FROM messages;
|
||||||
|
|
||||||
|
-- 2. Unlink or remove other tables that reference leases so we can delete leases
|
||||||
|
DELETE FROM lease_documents;
|
||||||
|
UPDATE tenant_invites SET lease_id = NULL;
|
||||||
|
UPDATE payment_transactions SET lease_id = NULL;
|
||||||
|
UPDATE reminders SET lease_id = NULL;
|
||||||
|
|
||||||
|
-- 3. Clear leases
|
||||||
|
DELETE FROM leases;
|
||||||
|
|
||||||
|
-- 4. Update constraint so future lease deletes don't block on messages
|
||||||
|
ALTER TABLE messages DROP CONSTRAINT IF EXISTS messages_lease_id_leases_id_fk;
|
||||||
|
ALTER TABLE messages ADD CONSTRAINT messages_lease_id_leases_id_fk
|
||||||
|
FOREIGN KEY (lease_id) REFERENCES public.leases(id) ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
7
scripts/fix-messages-lease-fk.sql
Normal file
7
scripts/fix-messages-lease-fk.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
-- Allow lease delete/update without being blocked by messages.
|
||||||
|
-- Run if you still see: violates foreign key constraint "messages_lease_id_leases_id_fk"
|
||||||
|
-- Usage: psql "<your connection string>" -f scripts/fix-messages-lease-fk.sql
|
||||||
|
|
||||||
|
ALTER TABLE "messages" DROP CONSTRAINT IF EXISTS "messages_lease_id_leases_id_fk";
|
||||||
|
ALTER TABLE "messages" ADD CONSTRAINT "messages_lease_id_leases_id_fk"
|
||||||
|
FOREIGN KEY ("lease_id") REFERENCES "public"."leases"("id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||||
@ -49,16 +49,18 @@ function buildAuthState(ctx: Context<State>) {
|
|||||||
const session = ctx.state.data;
|
const session = ctx.state.data;
|
||||||
const userId = session?.user_id?.trim() || null;
|
const userId = session?.user_id?.trim() || null;
|
||||||
const sessionRoles = parseRoles(session?.role);
|
const sessionRoles = parseRoles(session?.role);
|
||||||
const userType = normalizeRole(ctx.state.user?.user_type ?? null);
|
const userTypeFromDb = normalizeRole(ctx.state.user?.user_type ?? null);
|
||||||
|
const userTypeFromSession = normalizeRole(session?.user_type ?? null);
|
||||||
const roleSet = new Set<Role>([
|
const roleSet = new Set<Role>([
|
||||||
...sessionRoles,
|
...sessionRoles,
|
||||||
...(userType ? [userType] : []),
|
...(userTypeFromDb ? [userTypeFromDb] : []),
|
||||||
|
...(userTypeFromSession ? [userTypeFromSession] : []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAuthenticated: !!userId,
|
isAuthenticated: !!userId,
|
||||||
userId,
|
userId,
|
||||||
userType,
|
userType: userTypeFromDb ?? userTypeFromSession,
|
||||||
roles: Array.from(roleSet),
|
roles: Array.from(roleSet),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
193
services/expenseService.ts
Normal file
193
services/expenseService.ts
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
// services/expenseService.ts
|
||||||
|
// Handle property expense management and reporting
|
||||||
|
|
||||||
|
import { and, eq, between, desc } from "drizzle-orm";
|
||||||
|
import { db } from "../db/db.ts";
|
||||||
|
import { property_expenses } from "../db/schema.ts";
|
||||||
|
|
||||||
|
// Use the type from the schema - this matches what Drizzle returns
|
||||||
|
export type PropertyExpense = typeof property_expenses.$inferSelect;
|
||||||
|
|
||||||
|
export interface ExpenseSummary {
|
||||||
|
total: number;
|
||||||
|
byCategory: Record<string, number>;
|
||||||
|
byMonth: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new property expense record
|
||||||
|
*/
|
||||||
|
export async function createExpense(
|
||||||
|
property_id: string,
|
||||||
|
data: {
|
||||||
|
category: string;
|
||||||
|
amount: number;
|
||||||
|
description?: string;
|
||||||
|
expense_date: number;
|
||||||
|
payment_method?: string;
|
||||||
|
vendor?: string;
|
||||||
|
receipt_url?: string;
|
||||||
|
is_recurring?: boolean;
|
||||||
|
recurring_frequency?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const now = Date.now();
|
||||||
|
return await db.insert(property_expenses)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
property_id,
|
||||||
|
category: data.category,
|
||||||
|
amount: data.amount,
|
||||||
|
description: data.description || null,
|
||||||
|
expense_date: data.expense_date,
|
||||||
|
payment_method: data.payment_method || null,
|
||||||
|
vendor: data.vendor || null,
|
||||||
|
receipt_url: data.receipt_url || null,
|
||||||
|
is_recurring: data.is_recurring || false,
|
||||||
|
recurring_frequency: data.recurring_frequency || null,
|
||||||
|
created_on: now,
|
||||||
|
updated_on: now,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get expense by ID
|
||||||
|
*/
|
||||||
|
export async function getExpenseById(expenseId: string): Promise<PropertyExpense | null> {
|
||||||
|
const result = await db.select()
|
||||||
|
.from(property_expenses)
|
||||||
|
.where(eq(property_expenses.id, expenseId));
|
||||||
|
return result[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all expenses for a property
|
||||||
|
*/
|
||||||
|
export async function getExpensesByProperty(property_id: string): Promise<PropertyExpense[]> {
|
||||||
|
return await db.select()
|
||||||
|
.from(property_expenses)
|
||||||
|
.where(eq(property_expenses.property_id, property_id))
|
||||||
|
.orderBy(desc(property_expenses.expense_date));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get expenses for a property within a date range
|
||||||
|
*/
|
||||||
|
export async function getExpensesByPropertyAndDateRange(
|
||||||
|
property_id: string,
|
||||||
|
startDate: number,
|
||||||
|
endDate: number,
|
||||||
|
): Promise<PropertyExpense[]> {
|
||||||
|
return await db.select()
|
||||||
|
.from(property_expenses)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(property_expenses.property_id, property_id),
|
||||||
|
between(property_expenses.expense_date, startDate, endDate),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(property_expenses.expense_date));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get expenses by category for a property
|
||||||
|
*/
|
||||||
|
export async function getExpensesByPropertyAndCategory(
|
||||||
|
property_id: string,
|
||||||
|
category: string,
|
||||||
|
): Promise<PropertyExpense[]> {
|
||||||
|
return await db.select()
|
||||||
|
.from(property_expenses)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(property_expenses.property_id, property_id),
|
||||||
|
eq(property_expenses.category, category),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(property_expenses.expense_date));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recurring expenses for a property
|
||||||
|
*/
|
||||||
|
export async function getRecurringExpenses(property_id: string): Promise<PropertyExpense[]> {
|
||||||
|
return await db.select()
|
||||||
|
.from(property_expenses)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(property_expenses.property_id, property_id),
|
||||||
|
eq(property_expenses.is_recurring, true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(property_expenses.expense_date));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an expense
|
||||||
|
*/
|
||||||
|
export async function updateExpense(
|
||||||
|
expenseId: string,
|
||||||
|
updates: Partial<Omit<PropertyExpense, "id" | "property_id" | "created_on">>,
|
||||||
|
) {
|
||||||
|
const now = Date.now();
|
||||||
|
return await db.update(property_expenses)
|
||||||
|
.set({ ...updates, updated_on: now })
|
||||||
|
.where(eq(property_expenses.id, expenseId))
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an expense
|
||||||
|
*/
|
||||||
|
export async function deleteExpense(expenseId: string) {
|
||||||
|
return await db.delete(property_expenses)
|
||||||
|
.where(eq(property_expenses.id, expenseId))
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate expense summary for a property
|
||||||
|
*/
|
||||||
|
export async function getExpenseSummary(
|
||||||
|
property_id: string,
|
||||||
|
startDate?: number,
|
||||||
|
endDate?: number,
|
||||||
|
): Promise<ExpenseSummary> {
|
||||||
|
const expenses = startDate && endDate
|
||||||
|
? await getExpensesByPropertyAndDateRange(property_id, startDate, endDate)
|
||||||
|
: await getExpensesByProperty(property_id);
|
||||||
|
|
||||||
|
const total = expenses.reduce((sum, exp) => sum + (exp.amount || 0), 0);
|
||||||
|
|
||||||
|
const byCategory: Record<string, number> = {};
|
||||||
|
const byMonth: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const exp of expenses) {
|
||||||
|
const category = exp.category || "uncategorized";
|
||||||
|
byCategory[category] = (byCategory[category] || 0) + (exp.amount || 0);
|
||||||
|
|
||||||
|
if (exp.expense_date) {
|
||||||
|
const date = new Date(exp.expense_date);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
byMonth[monthKey] = (byMonth[monthKey] || 0) + (exp.amount || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total, byCategory, byMonth };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total expenses for a property (all time or within date range)
|
||||||
|
*/
|
||||||
|
export async function getTotalExpenses(
|
||||||
|
property_id: string,
|
||||||
|
startDate?: number,
|
||||||
|
endDate?: number,
|
||||||
|
): Promise<number> {
|
||||||
|
const expenses = startDate && endDate
|
||||||
|
? await getExpensesByPropertyAndDateRange(property_id, startDate, endDate)
|
||||||
|
: await getExpensesByProperty(property_id);
|
||||||
|
|
||||||
|
return expenses.reduce((sum, exp) => sum + (exp.amount || 0), 0);
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
// services/landlordService.ts
|
// services/landlordService.ts
|
||||||
// Handle landlord-specific operations, onboarding workflow, and dashboard data
|
// Handle landlord-specific operations, onboarding workflow, and dashboard data
|
||||||
|
|
||||||
import { and, desc, eq, or } from "drizzle-orm";
|
import { and, desc, eq, inArray, or } from "drizzle-orm";
|
||||||
import { db } from "../db/db.ts";
|
import { db } from "../db/db.ts";
|
||||||
import {
|
import {
|
||||||
leases,
|
leases,
|
||||||
@ -175,12 +175,24 @@ export async function getLandlordOnboardingStatus(
|
|||||||
export async function getPendingLeaseApprovals(
|
export async function getPendingLeaseApprovals(
|
||||||
landlord_id: string,
|
landlord_id: string,
|
||||||
) {
|
) {
|
||||||
|
// First get property IDs owned by this landlord
|
||||||
|
const landlordProperties = await db.select({ id: properties.id })
|
||||||
|
.from(properties)
|
||||||
|
.where(eq(properties.owner_id, landlord_id));
|
||||||
|
|
||||||
|
const propertyIds = landlordProperties.map((p) => p.id);
|
||||||
|
|
||||||
|
if (propertyIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query by property ownership since pending leases have owner_id=null
|
||||||
const pendingLeases = await db.select()
|
const pendingLeases = await db.select()
|
||||||
.from(leases)
|
.from(leases)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(leases.owner_id, landlord_id),
|
|
||||||
eq(leases.approval_status, "pending_approval"),
|
eq(leases.approval_status, "pending_approval"),
|
||||||
|
inArray(leases.property_id, propertyIds),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(leases.approval_requested_at);
|
.orderBy(leases.approval_requested_at);
|
||||||
@ -189,10 +201,6 @@ export async function getPendingLeaseApprovals(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const propertyIds = pendingLeases
|
|
||||||
.map((lease) => lease.property_id)
|
|
||||||
.filter((id): id is string => Boolean(id));
|
|
||||||
|
|
||||||
const tenantIds = pendingLeases
|
const tenantIds = pendingLeases
|
||||||
.map((lease) => lease.tenant_id)
|
.map((lease) => lease.tenant_id)
|
||||||
.filter((id): id is string => Boolean(id));
|
.filter((id): id is string => Boolean(id));
|
||||||
@ -213,7 +221,7 @@ export async function getPendingLeaseApprovals(
|
|||||||
bathrooms: properties.bathrooms,
|
bathrooms: properties.bathrooms,
|
||||||
})
|
})
|
||||||
.from(properties)
|
.from(properties)
|
||||||
.where(eq(properties.id, propertyIds[0]))
|
.where(inArray(properties.id, propertyIds))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const tenantsData = tenantIds.length > 0
|
const tenantsData = tenantIds.length > 0
|
||||||
@ -225,7 +233,7 @@ export async function getPendingLeaseApprovals(
|
|||||||
phone: users.phone,
|
phone: users.phone,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.id, tenantIds[0]))
|
.where(inArray(users.id, tenantIds))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const propertyMap = new Map(
|
const propertyMap = new Map(
|
||||||
@ -235,11 +243,13 @@ export async function getPendingLeaseApprovals(
|
|||||||
tenantsData.map((t) => [t.id, t]),
|
tenantsData.map((t) => [t.id, t]),
|
||||||
);
|
);
|
||||||
|
|
||||||
return pendingLeases.map((lease) => ({
|
const result = pendingLeases.map((lease) => ({
|
||||||
...lease,
|
...lease,
|
||||||
property: lease.property_id ? propertyMap.get(lease.property_id) : null,
|
property: lease.property_id ? propertyMap.get(lease.property_id) : null,
|
||||||
tenant: lease.tenant_id ? tenantMap.get(lease.tenant_id) : null,
|
tenant: lease.tenant_id ? tenantMap.get(lease.tenant_id) : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get landlord dashboard data
|
// Get landlord dashboard data
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
// services/leaseService.ts
|
// services/leaseService.ts
|
||||||
// Handle lease management, creation, updates, and tenant relationships
|
// Handle lease management, creation, updates, and tenant relationships
|
||||||
|
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { db } from "../db/db.ts";
|
import { db } from "../db/db.ts";
|
||||||
import { leases, tenant_invites } from "../db/schema.ts";
|
import { leases, properties, tenant_invites } from "../db/schema.ts";
|
||||||
import { Lease } from "../db/interfaces.ts";
|
import { Lease } from "../db/interfaces.ts";
|
||||||
|
import { getPropertyById } from "./propertyService.ts";
|
||||||
|
|
||||||
// export interface Lease {
|
// export interface Lease {
|
||||||
// id: string;
|
// id: string;
|
||||||
@ -323,17 +324,35 @@ export async function requestLeaseApproval(
|
|||||||
_invite_id: string,
|
_invite_id: string,
|
||||||
) {
|
) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Fetch property to populate lease details
|
||||||
|
const property = await getPropertyById(property_id);
|
||||||
|
|
||||||
|
// Build property address from components
|
||||||
|
const propertyAddress = property
|
||||||
|
? [
|
||||||
|
property.address_1,
|
||||||
|
property.address_2,
|
||||||
|
property.city,
|
||||||
|
property.state,
|
||||||
|
property.zip,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ")
|
||||||
|
: "";
|
||||||
|
|
||||||
return await db.insert(leases)
|
return await db.insert(leases)
|
||||||
.values({
|
.values({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
title: `Lease for Property ${property_id}`,
|
title: property?.title || `Lease for Property ${property_id}`,
|
||||||
property_id,
|
property_id,
|
||||||
|
property_address: propertyAddress,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
owner_id: null,
|
owner_id: null,
|
||||||
start_date: now,
|
start_date: now,
|
||||||
end_date: now + 365 * 24 * 60 * 60 * 1000,
|
end_date: now + 365 * 24 * 60 * 60 * 1000,
|
||||||
rent: null,
|
rent: property?.monthly_rent || null,
|
||||||
security_deposit: null,
|
security_deposit: property?.security_deposit || null,
|
||||||
lease_status: "draft",
|
lease_status: "draft",
|
||||||
approval_status: "pending_approval",
|
approval_status: "pending_approval",
|
||||||
approval_requested_at: now,
|
approval_requested_at: now,
|
||||||
@ -355,22 +374,33 @@ export async function requestLeaseApproval(
|
|||||||
export async function approveLease(
|
export async function approveLease(
|
||||||
lease_id: string,
|
lease_id: string,
|
||||||
landlord_id: string,
|
landlord_id: string,
|
||||||
|
property_id?: string,
|
||||||
) {
|
) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
// If property_id provided, verify landlord owns the property
|
||||||
|
// (pending leases have owner_id=null, so we check property ownership)
|
||||||
|
if (property_id) {
|
||||||
|
const property = await db.select({ owner_id: properties.owner_id })
|
||||||
|
.from(properties)
|
||||||
|
.where(eq(properties.id, property_id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!property || property[0]?.owner_id !== landlord_id) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return await db.update(leases)
|
return await db.update(leases)
|
||||||
.set({
|
.set({
|
||||||
approval_status: "approved",
|
approval_status: "approved",
|
||||||
approved_at: now,
|
approved_at: now,
|
||||||
approved_by: landlord_id,
|
approved_by: landlord_id,
|
||||||
|
owner_id: landlord_id, // Set owner_id on approval
|
||||||
lease_status: "active",
|
lease_status: "active",
|
||||||
updated_on: now,
|
updated_on: now,
|
||||||
})
|
})
|
||||||
.where(
|
.where(eq(leases.id, lease_id))
|
||||||
and(
|
|
||||||
eq(leases.id, lease_id),
|
|
||||||
eq(leases.owner_id, landlord_id),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.returning();
|
.returning();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -379,32 +409,58 @@ export async function rejectLease(
|
|||||||
lease_id: string,
|
lease_id: string,
|
||||||
landlord_id: string,
|
landlord_id: string,
|
||||||
rejection_reason?: string,
|
rejection_reason?: string,
|
||||||
|
property_id?: string,
|
||||||
) {
|
) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
// If property_id provided, verify landlord owns the property
|
||||||
|
// (pending leases have owner_id=null, so we check property ownership)
|
||||||
|
if (property_id) {
|
||||||
|
const property = await db.select({ owner_id: properties.owner_id })
|
||||||
|
.from(properties)
|
||||||
|
.where(eq(properties.id, property_id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!property || property[0]?.owner_id !== landlord_id) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return await db.update(leases)
|
return await db.update(leases)
|
||||||
.set({
|
.set({
|
||||||
approval_status: "rejected",
|
approval_status: "rejected",
|
||||||
rejection_reason: rejection_reason || null,
|
rejection_reason: rejection_reason || null,
|
||||||
updated_on: now,
|
updated_on: now,
|
||||||
})
|
})
|
||||||
.where(
|
.where(eq(leases.id, lease_id))
|
||||||
and(
|
|
||||||
eq(leases.id, lease_id),
|
|
||||||
eq(leases.owner_id, landlord_id),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.returning();
|
.returning();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get pending lease approvals for a landlord
|
// Get pending lease approvals for a landlord
|
||||||
|
// Queries by property ownership since pending leases have owner_id=null
|
||||||
export async function getPendingApprovals(landlord_id: string) {
|
export async function getPendingApprovals(landlord_id: string) {
|
||||||
return await db.select()
|
// First get property IDs owned by this landlord
|
||||||
|
const landlordProperties = await db.select({ id: properties.id })
|
||||||
|
.from(properties)
|
||||||
|
.where(eq(properties.owner_id, landlord_id));
|
||||||
|
|
||||||
|
const propertyIds = landlordProperties.map((p) => p.id);
|
||||||
|
|
||||||
|
if (propertyIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then get pending leases for those properties
|
||||||
|
const pendingLeases = await db.select()
|
||||||
.from(leases)
|
.from(leases)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(leases.owner_id, landlord_id),
|
|
||||||
eq(leases.approval_status, "pending_approval"),
|
eq(leases.approval_status, "pending_approval"),
|
||||||
|
// Only return leases for properties owned by this landlord
|
||||||
|
inArray(leases.property_id, propertyIds),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(leases.approval_requested_at);
|
.orderBy(leases.approval_requested_at);
|
||||||
|
|
||||||
|
return pendingLeases;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,24 @@ export interface MessagePage {
|
|||||||
unread: number;
|
unread: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ThreadSummary {
|
||||||
|
rootMessageId: string;
|
||||||
|
subject: string;
|
||||||
|
latestMessage: DashboardMessage;
|
||||||
|
hasUnread: boolean;
|
||||||
|
messageCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadPage {
|
||||||
|
threads: ThreadSummary[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalThreads: number;
|
||||||
|
totalPages: number;
|
||||||
|
unread: number;
|
||||||
|
}
|
||||||
|
|
||||||
function formatPropertyAddress(
|
function formatPropertyAddress(
|
||||||
property: {
|
property: {
|
||||||
address_1?: string | null;
|
address_1?: string | null;
|
||||||
@ -299,6 +317,225 @@ export async function getMessagesForUser(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const THREAD_FETCH_LIMIT = 2000;
|
||||||
|
|
||||||
|
export async function getThreadsForUser(
|
||||||
|
user_id: string,
|
||||||
|
options: MessageQueryOptions = {},
|
||||||
|
): Promise<ThreadPage> {
|
||||||
|
const { page, pageSize, sort, status, type, direction, search } =
|
||||||
|
normalizeOptions(options);
|
||||||
|
|
||||||
|
const directionClause = direction === "sent"
|
||||||
|
? eq(messages.sender_id, user_id)
|
||||||
|
: direction === "received"
|
||||||
|
? eq(messages.recipient_id, user_id)
|
||||||
|
: or(eq(messages.sender_id, user_id), eq(messages.recipient_id, user_id));
|
||||||
|
|
||||||
|
const clauses = [directionClause];
|
||||||
|
if (type !== "all") {
|
||||||
|
clauses.push(eq(messages.message_type, type));
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
const pattern = `%${search}%`;
|
||||||
|
clauses.push(or(
|
||||||
|
ilike(messages.subject, pattern),
|
||||||
|
ilike(messages.content, pattern),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
const whereClause = clauses.length > 1 ? and(...clauses) : clauses[0];
|
||||||
|
|
||||||
|
const rawMessages = await db.select()
|
||||||
|
.from(messages)
|
||||||
|
.where(whereClause)
|
||||||
|
.orderBy(
|
||||||
|
sort === "oldest" ? asc(messages.created_on) : desc(messages.created_on),
|
||||||
|
)
|
||||||
|
.limit(THREAD_FETCH_LIMIT) as unknown as Message[];
|
||||||
|
|
||||||
|
type Group = {
|
||||||
|
rootId: string;
|
||||||
|
subject: string;
|
||||||
|
list: Message[];
|
||||||
|
hasUnread: boolean;
|
||||||
|
};
|
||||||
|
const groupMap = new Map<string, Group>();
|
||||||
|
|
||||||
|
for (const m of rawMessages) {
|
||||||
|
const rootId = m.parent_message_id || m.id;
|
||||||
|
const existing = groupMap.get(rootId);
|
||||||
|
const isUnread = m.recipient_id === user_id && !m.is_read;
|
||||||
|
if (!existing) {
|
||||||
|
const rootSubject = rootId === m.id ? (m.subject || "No subject") : "";
|
||||||
|
groupMap.set(rootId, {
|
||||||
|
rootId,
|
||||||
|
subject: rootId === m.id ? (m.subject || "No subject") : "",
|
||||||
|
list: [m],
|
||||||
|
hasUnread: isUnread,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (rootId === m.id) existing.subject = m.subject || "No subject";
|
||||||
|
existing.list.push(m);
|
||||||
|
if (isUnread) existing.hasUnread = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const g of groupMap.values()) {
|
||||||
|
if (!g.subject && g.list.length > 0) {
|
||||||
|
const rootMsg = g.list.find((m) => m.id === g.rootId) ?? g.list[0];
|
||||||
|
g.subject = rootMsg.subject || "No subject";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let threadList = Array.from(groupMap.values());
|
||||||
|
|
||||||
|
if (status === "unread") {
|
||||||
|
threadList = threadList.filter((t) => t.hasUnread);
|
||||||
|
} else if (status === "read") {
|
||||||
|
threadList = threadList.filter((t) => !t.hasUnread);
|
||||||
|
}
|
||||||
|
|
||||||
|
threadList.sort((a, b) => {
|
||||||
|
const aLatest = Math.max(...a.list.map((m) => m.created_on ?? 0));
|
||||||
|
const bLatest = Math.max(...b.list.map((m) => m.created_on ?? 0));
|
||||||
|
return sort === "oldest" ? aLatest - bLatest : bLatest - aLatest;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalThreads = threadList.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalThreads / pageSize));
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const pageThreads = threadList.slice(offset, offset + pageSize);
|
||||||
|
|
||||||
|
const unreadResult = await db.select({
|
||||||
|
count: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(messages)
|
||||||
|
.where(and(
|
||||||
|
eq(messages.recipient_id, user_id),
|
||||||
|
eq(messages.is_read, false),
|
||||||
|
));
|
||||||
|
const unread = unreadResult[0]?.count ?? 0;
|
||||||
|
|
||||||
|
const userIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
pageThreads.flatMap((t) =>
|
||||||
|
t.list.flatMap((m) => [m.sender_id, m.recipient_id])
|
||||||
|
).filter(Boolean) as string[],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const userMap = new Map<string, { name: string | null }>();
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
const userRows = await db.select({ id: users.id, name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.id, userIds));
|
||||||
|
userRows.forEach((row) => userMap.set(row.id, { name: row.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaseIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
pageThreads.flatMap((t) =>
|
||||||
|
t.list.map((m) => m.lease_id).filter(Boolean) as string[]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const leaseMap = new Map<
|
||||||
|
string,
|
||||||
|
{ property_id: string | null; property_address: string | null }
|
||||||
|
>();
|
||||||
|
if (leaseIds.length > 0) {
|
||||||
|
const leaseRows = await db.select({
|
||||||
|
id: leases.id,
|
||||||
|
property_id: leases.property_id,
|
||||||
|
property_address: leases.property_address,
|
||||||
|
})
|
||||||
|
.from(leases)
|
||||||
|
.where(inArray(leases.id, leaseIds));
|
||||||
|
leaseRows.forEach((row) =>
|
||||||
|
leaseMap.set(row.id, {
|
||||||
|
property_id: row.property_id,
|
||||||
|
property_address: row.property_address,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const propertyIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
Array.from(leaseMap.values())
|
||||||
|
.map((l) => l.property_id)
|
||||||
|
.filter(Boolean) as string[],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const propertyMap = new Map<string, string>();
|
||||||
|
if (propertyIds.length > 0) {
|
||||||
|
const propertyRows = await db.select({
|
||||||
|
id: properties.id,
|
||||||
|
address_1: properties.address_1,
|
||||||
|
address_2: properties.address_2,
|
||||||
|
city: properties.city,
|
||||||
|
state: properties.state,
|
||||||
|
zip: properties.zip,
|
||||||
|
})
|
||||||
|
.from(properties)
|
||||||
|
.where(inArray(properties.id, propertyIds));
|
||||||
|
propertyRows.forEach((p) =>
|
||||||
|
propertyMap.set(p.id, formatPropertyAddress(p))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threads: ThreadSummary[] = pageThreads.map((t) => {
|
||||||
|
const latest = t.list.reduce((prev, m) =>
|
||||||
|
(m.created_on ?? 0) > (prev.created_on ?? 0) ? m : prev
|
||||||
|
);
|
||||||
|
const sender = latest.sender_id ? userMap.get(latest.sender_id) : null;
|
||||||
|
const recipient = latest.recipient_id
|
||||||
|
? userMap.get(latest.recipient_id)
|
||||||
|
: null;
|
||||||
|
const lease = latest.lease_id ? leaseMap.get(latest.lease_id) : null;
|
||||||
|
const propertyAddress = lease?.property_address ||
|
||||||
|
(lease?.property_id ? propertyMap.get(lease.property_id) : "") || "";
|
||||||
|
|
||||||
|
const latestEnriched: DashboardMessage = {
|
||||||
|
...latest,
|
||||||
|
sender_name: sender?.name ?? "Sender",
|
||||||
|
recipient_name: recipient?.name ?? "Recipient",
|
||||||
|
property_address: propertyAddress,
|
||||||
|
created_at: latest.created_on ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
rootMessageId: t.rootId,
|
||||||
|
subject: t.subject,
|
||||||
|
latestMessage: latestEnriched,
|
||||||
|
hasUnread: t.hasUnread,
|
||||||
|
messageCount: t.list.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
threads,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total: totalThreads,
|
||||||
|
totalThreads,
|
||||||
|
totalPages,
|
||||||
|
unread,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markThreadAsRead(
|
||||||
|
user_id: string,
|
||||||
|
root_message_id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const thread = await getMessageThread(root_message_id);
|
||||||
|
if (!thread || thread.length === 0) return;
|
||||||
|
const ids = thread
|
||||||
|
.filter((m) => m.recipient_id === user_id && !m.is_read)
|
||||||
|
.map((m) => m.id);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await markMessagesAsRead(user_id, ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get unread messages for a user
|
// Get unread messages for a user
|
||||||
export async function getUnreadMessages(user_id: string) {
|
export async function getUnreadMessages(user_id: string) {
|
||||||
return await db.select()
|
return await db.select()
|
||||||
|
|||||||
@ -58,8 +58,8 @@ export async function startGuestOnboarding(
|
|||||||
return { nextUrl: "/landlord/onboarding/profile" };
|
return { nextUrl: "/landlord/onboarding/profile" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// tenant
|
// tenant: send to tenant onboarding to collect basic info first
|
||||||
return { nextUrl: "/tenant/dashboard" };
|
return { nextUrl: "/tenant/onboarding" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOnboardingStatus(
|
export async function getOnboardingStatus(
|
||||||
|
|||||||
184
services/rentChangeService.ts
Normal file
184
services/rentChangeService.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
// services/rentChangeService.ts
|
||||||
|
// Handle rent change history tracking for properties
|
||||||
|
|
||||||
|
import { eq, desc } from "drizzle-orm";
|
||||||
|
import { db } from "../db/db.ts";
|
||||||
|
import { rent_changes, properties } from "../db/schema.ts";
|
||||||
|
|
||||||
|
// Use the type from the schema
|
||||||
|
export type RentChange = typeof rent_changes.$inferSelect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new rent change record
|
||||||
|
* Also updates the property's monthly_rent to the new value
|
||||||
|
*/
|
||||||
|
export async function createRentChange(
|
||||||
|
property_id: string,
|
||||||
|
effective_date: number,
|
||||||
|
monthly_rent: number,
|
||||||
|
options: {
|
||||||
|
reason?: string;
|
||||||
|
created_by?: string;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
// First, get the current rent from the property
|
||||||
|
const propertyResult = await db.select({
|
||||||
|
monthly_rent: properties.monthly_rent,
|
||||||
|
})
|
||||||
|
.from(properties)
|
||||||
|
.where(eq(properties.id, property_id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const previous_rent = propertyResult[0]?.monthly_rent || null;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Create the rent change record
|
||||||
|
const result = await db.insert(rent_changes)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
property_id,
|
||||||
|
effective_date,
|
||||||
|
monthly_rent,
|
||||||
|
previous_rent,
|
||||||
|
reason: options.reason || null,
|
||||||
|
created_by: options.created_by || null,
|
||||||
|
created_on: now,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Update the property with the new rent
|
||||||
|
await db.update(properties)
|
||||||
|
.set({ monthly_rent, updated_on: now })
|
||||||
|
.where(eq(properties.id, property_id));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all rent changes for a property, ordered by effective date (newest first)
|
||||||
|
*/
|
||||||
|
export async function getRentChangesByProperty(property_id: string): Promise<RentChange[]> {
|
||||||
|
return await db.select()
|
||||||
|
.from(rent_changes)
|
||||||
|
.where(eq(rent_changes.property_id, property_id))
|
||||||
|
.orderBy(desc(rent_changes.effective_date));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current rent for a property (most recent effective date that has passed)
|
||||||
|
*/
|
||||||
|
export async function getCurrentRentForProperty(property_id: string): Promise<number | null> {
|
||||||
|
const now = Date.now();
|
||||||
|
const result = await db.select()
|
||||||
|
.from(rent_changes)
|
||||||
|
.where(eq(rent_changes.property_id, property_id))
|
||||||
|
.orderBy(desc(rent_changes.effective_date))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// If there are rent changes, return the most recent one
|
||||||
|
if (result.length > 0) {
|
||||||
|
return result[0].monthly_rent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, fall back to the property's base monthly_rent
|
||||||
|
const propertyResult = await db.select({
|
||||||
|
monthly_rent: properties.monthly_rent,
|
||||||
|
})
|
||||||
|
.from(properties)
|
||||||
|
.where(eq(properties.id, property_id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return propertyResult[0]?.monthly_rent || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rent change by ID
|
||||||
|
*/
|
||||||
|
export async function getRentChangeById(changeId: string): Promise<RentChange | null> {
|
||||||
|
const result = await db.select()
|
||||||
|
.from(rent_changes)
|
||||||
|
.where(eq(rent_changes.id, changeId));
|
||||||
|
return result[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upcoming rent changes for a property (effective_date is in the future)
|
||||||
|
*/
|
||||||
|
export async function getUpcomingRentChanges(property_id: string): Promise<RentChange[]> {
|
||||||
|
const now = Date.now();
|
||||||
|
return await db.select()
|
||||||
|
.from(rent_changes)
|
||||||
|
.where(eq(rent_changes.property_id, property_id))
|
||||||
|
.orderBy(desc(rent_changes.effective_date));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rent history for a property including current rent from the property record
|
||||||
|
*/
|
||||||
|
export async function getRentHistory(property_id: string): Promise<Array<{
|
||||||
|
effective_date: number;
|
||||||
|
monthly_rent: number;
|
||||||
|
previous_rent: number | null;
|
||||||
|
reason: string | null;
|
||||||
|
is_current: boolean;
|
||||||
|
}>> {
|
||||||
|
// Get the property's current rent
|
||||||
|
const propertyResult = await db.select({
|
||||||
|
monthly_rent: properties.monthly_rent,
|
||||||
|
updated_on: properties.updated_on,
|
||||||
|
})
|
||||||
|
.from(properties)
|
||||||
|
.where(eq(properties.id, property_id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const property = propertyResult[0];
|
||||||
|
|
||||||
|
// Get all rent changes
|
||||||
|
const changes = await getRentChangesByProperty(property_id);
|
||||||
|
|
||||||
|
// Build history including current property rent as the baseline
|
||||||
|
const history: Array<{
|
||||||
|
effective_date: number;
|
||||||
|
monthly_rent: number;
|
||||||
|
previous_rent: number | null;
|
||||||
|
reason: string | null;
|
||||||
|
is_current: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Add current property rent as the starting point
|
||||||
|
if (property && property.monthly_rent) {
|
||||||
|
history.push({
|
||||||
|
effective_date: property.updated_on || Date.now(),
|
||||||
|
monthly_rent: property.monthly_rent,
|
||||||
|
previous_rent: null,
|
||||||
|
reason: "Initial rent",
|
||||||
|
is_current: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all historical changes
|
||||||
|
for (const change of changes) {
|
||||||
|
history.push({
|
||||||
|
effective_date: change.effective_date || 0,
|
||||||
|
monthly_rent: change.monthly_rent || 0,
|
||||||
|
previous_rent: change.previous_rent,
|
||||||
|
reason: change.reason,
|
||||||
|
is_current: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by effective date descending (newest first)
|
||||||
|
history.sort((a, b) => b.effective_date - a.effective_date);
|
||||||
|
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a rent change record
|
||||||
|
*/
|
||||||
|
export async function deleteRentChange(changeId: string) {
|
||||||
|
return await db.delete(rent_changes)
|
||||||
|
.where(eq(rent_changes.id, changeId))
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
@ -34,7 +34,10 @@ function hasMatchingInviteEmail(
|
|||||||
export async function createTenantInvite(
|
export async function createTenantInvite(
|
||||||
landlordId: string,
|
landlordId: string,
|
||||||
propertyId: string,
|
propertyId: string,
|
||||||
options: { availableOn?: number | null } = {},
|
options: {
|
||||||
|
availableOn?: number | null;
|
||||||
|
invitedEmails?: string[];
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
const existing = await db.select().from(tenant_invites).where(
|
const existing = await db.select().from(tenant_invites).where(
|
||||||
and(
|
and(
|
||||||
@ -46,12 +49,15 @@ export async function createTenantInvite(
|
|||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const invitedEmails = options.invitedEmails ?? [];
|
||||||
return await db.insert(tenant_invites).values({
|
return await db.insert(tenant_invites).values({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
landlord_id: landlordId,
|
landlord_id: landlordId,
|
||||||
property_id: propertyId,
|
property_id: propertyId,
|
||||||
available_on: options.availableOn ?? null,
|
available_on: options.availableOn ?? null,
|
||||||
invited_emails: JSON.stringify([]),
|
invited_emails: JSON.stringify(
|
||||||
|
invitedEmails.map((e) => String(e).trim()).filter(Boolean),
|
||||||
|
),
|
||||||
created_on: now,
|
created_on: now,
|
||||||
updated_on: now,
|
updated_on: now,
|
||||||
}).returning();
|
}).returning();
|
||||||
@ -90,16 +96,30 @@ export async function updateTenantInvite(
|
|||||||
.returning();
|
.returning();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extract plain email from "Display Name <email@example.com>" or return trimmed input. */
|
||||||
|
function toPlainEmail(email: string): string {
|
||||||
|
const trimmed = email.trim();
|
||||||
|
const match = trimmed.match(/<([^>]+)>/);
|
||||||
|
return (match ? match[1].trim() : trimmed).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTenantInvitesByEmail(email: string) {
|
export async function getTenantInvitesByEmail(email: string) {
|
||||||
const normalizedEmail = email.trim().toLowerCase();
|
|
||||||
if (!normalizedEmail) return [];
|
const normalizedEmail = toPlainEmail(email);
|
||||||
|
|
||||||
|
if (!normalizedEmail) {
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// ILIKE substring match then exact filter (handles JSON array and comma-separated stored format).
|
||||||
const pattern = `%${normalizedEmail}%`;
|
const pattern = `%${normalizedEmail}%`;
|
||||||
const result = await db.select().from(tenant_invites).where(
|
const result = await db.select().from(tenant_invites).where(
|
||||||
ilike(tenant_invites.invited_emails, pattern),
|
ilike(tenant_invites.invited_emails, pattern),
|
||||||
);
|
);
|
||||||
return result.filter((invite) =>
|
return result.filter((invite) => {
|
||||||
hasMatchingInviteEmail(invite, normalizedEmail)
|
|
||||||
);
|
return hasMatchingInviteEmail(invite, normalizedEmail);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeTenantInviteEmail(inviteId: string, email: string) {
|
export async function removeTenantInviteEmail(inviteId: string, email: string) {
|
||||||
|
|||||||
@ -342,7 +342,7 @@ export const ROUTES = {
|
|||||||
tenantMessages: "/tenant/messages",
|
tenantMessages: "/tenant/messages",
|
||||||
tenantMaintenance: "/tenant/maintenance",
|
tenantMaintenance: "/tenant/maintenance",
|
||||||
onboardingLandlord: "/onboarding/landlord",
|
onboardingLandlord: "/onboarding/landlord",
|
||||||
onboardingTenant: "/onboarding/tenant",
|
onboardingTenant: "/tenant/onboarding",
|
||||||
authLogin: "/auth/login",
|
authLogin: "/auth/login",
|
||||||
authLogout: "/auth/logout",
|
authLogout: "/auth/logout",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user