Add Done button, tablet controls, super admin management, midnight strike reset, and activity log improvements
- Done button snaps block to full duration, marks complete, logs "Marked Done by User"; Reset after Done fully un-completes the block - Session action buttons stretch full-width and double height for tablet tapping - Super admin: reset password, disable/enable accounts, delete user (with cascade), last active date per user's timezone - Disabled account login returns specific error message instead of generic invalid credentials - Users can change own password from Admin → Settings - Strikes reset automatically at midnight in user's configured timezone (lazy reset on page load) - Break timer state fully restored when navigating away and back to dashboard - Timer no longer auto-starts on navigation if it wasn't running before - Implicit pause guard: no duplicate pause events when switching already-paused blocks or starting a break - Block selection events removed from activity log; all event types have human-readable labels - House emoji favicon via inline SVG data URI - README updated to reflect all changes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Homeschool Dashboard</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏠</text></svg>" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -48,20 +48,35 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
// Restore elapsed time from server-computed value and seed the per-block cache
|
||||
const serverElapsed = snapshot.block_elapsed_seconds || 0
|
||||
if (snapshot.session?.current_block_id) {
|
||||
blockElapsedCache.value[snapshot.session.current_block_id] = serverElapsed
|
||||
blockElapsedOffset.value = serverElapsed
|
||||
// Start the live counter only when the block is actually running (not paused).
|
||||
// Use serverElapsed == 0 is fine here — a just-reset block is still running.
|
||||
blockStartedAt.value = isPaused.value ? null : Date.now()
|
||||
const blockId = snapshot.session.current_block_id
|
||||
// If the current block is already completed (Done was pressed), snap elapsed
|
||||
// to the full block duration so the timer shows 00:00 / Done! on reload.
|
||||
const isCompleted = completedBlockIds.value.includes(blockId)
|
||||
const block = blocks.value.find(b => b.id === blockId)
|
||||
const elapsed = isCompleted && block
|
||||
? (block.duration_minutes || 0) * 60
|
||||
: serverElapsed
|
||||
blockElapsedCache.value[blockId] = elapsed
|
||||
blockElapsedOffset.value = elapsed
|
||||
blockStartedAt.value = (isPaused.value || isCompleted) ? null : Date.now()
|
||||
} else {
|
||||
blockElapsedOffset.value = 0
|
||||
blockStartedAt.value = null
|
||||
}
|
||||
// Reset break state on snapshot (not persisted across page loads)
|
||||
isBreakMode.value = false
|
||||
breakStartedAt.value = null
|
||||
breakElapsedOffset.value = 0
|
||||
breakElapsedCache.value = {}
|
||||
// Restore break state from server
|
||||
if (snapshot.is_break_active && snapshot.session?.current_block_id) {
|
||||
const blockId = snapshot.session.current_block_id
|
||||
const breakElapsed = snapshot.break_elapsed_seconds || 0
|
||||
isBreakMode.value = true
|
||||
breakElapsedOffset.value = breakElapsed
|
||||
breakElapsedCache.value[blockId] = breakElapsed
|
||||
breakStartedAt.value = snapshot.is_break_paused ? null : Date.now()
|
||||
} else {
|
||||
isBreakMode.value = false
|
||||
breakStartedAt.value = null
|
||||
breakElapsedOffset.value = 0
|
||||
breakElapsedCache.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
function applyWsEvent(event) {
|
||||
@@ -150,12 +165,15 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
breakStartedAt.value = null
|
||||
breakElapsedOffset.value = breakElapsedCache.value[event.block_id] ?? 0
|
||||
}
|
||||
// Reset — clear elapsed to 0 and start counting immediately
|
||||
// Reset — clear elapsed to 0, stay paused, and un-complete the block
|
||||
if (event.event === 'reset') {
|
||||
if (event.block_id) blockElapsedCache.value[event.block_id] = 0
|
||||
blockElapsedOffset.value = 0
|
||||
blockStartedAt.value = Date.now()
|
||||
isPaused.value = false
|
||||
blockStartedAt.value = null
|
||||
isPaused.value = true
|
||||
if (event.uncomplete_block_id) {
|
||||
completedBlockIds.value = completedBlockIds.value.filter(id => id !== event.uncomplete_block_id)
|
||||
}
|
||||
}
|
||||
// Select — switch current block but keep timer stopped (manual start required)
|
||||
if (event.event === 'select') {
|
||||
@@ -337,14 +355,27 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
sendTimerAction(sessionId, 'break_reset', blockId)
|
||||
}
|
||||
|
||||
// Mark the current block as done: snap elapsed to full duration and send complete.
|
||||
function markBlockDone(sessionId) {
|
||||
if (!session.value?.current_block_id || !currentBlock.value) return
|
||||
const blockId = session.value.current_block_id
|
||||
const fullDuration = (currentBlock.value.duration_minutes || 0) * 60
|
||||
blockElapsedOffset.value = fullDuration
|
||||
blockElapsedCache.value[blockId] = fullDuration
|
||||
blockStartedAt.value = null
|
||||
isPaused.value = true
|
||||
sendTimerAction(sessionId, 'complete', blockId)
|
||||
}
|
||||
|
||||
// Reset the current block's timer to 0 and start counting immediately.
|
||||
function resetCurrentBlock(sessionId) {
|
||||
if (!session.value?.current_block_id) return
|
||||
const blockId = session.value.current_block_id
|
||||
blockElapsedCache.value[blockId] = 0
|
||||
blockElapsedOffset.value = 0
|
||||
isPaused.value = false
|
||||
blockStartedAt.value = Date.now()
|
||||
isPaused.value = true
|
||||
blockStartedAt.value = null
|
||||
completedBlockIds.value = completedBlockIds.value.filter(id => id !== blockId)
|
||||
sendTimerAction(sessionId, 'reset', blockId)
|
||||
}
|
||||
|
||||
@@ -374,6 +405,7 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
selectBlock,
|
||||
startCurrentBlock,
|
||||
resumeCurrentBlock,
|
||||
markBlockDone,
|
||||
resetCurrentBlock,
|
||||
startBreak,
|
||||
pauseBreak,
|
||||
|
||||
@@ -343,10 +343,43 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-divider"></div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-label">
|
||||
<div class="settings-title">Password</div>
|
||||
<div class="settings-hint">Change your account login password.</div>
|
||||
</div>
|
||||
<div class="settings-control">
|
||||
<button class="btn-sm" @click="showPasswordDialog = true">Reset Password</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Dialog -->
|
||||
<div class="dialog-overlay" v-if="showPasswordDialog" @click.self="closePasswordDialog">
|
||||
<div class="dialog">
|
||||
<h2>Change Password</h2>
|
||||
<div class="field">
|
||||
<label>Current Password</label>
|
||||
<input v-model="currentPassword" type="password" placeholder="Enter current password" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>New Password</label>
|
||||
<input v-model="newPassword" type="password" placeholder="Enter new password" @keyup.enter="submitPasswordChange" />
|
||||
</div>
|
||||
<p v-if="passwordError" class="pw-error">{{ passwordError }}</p>
|
||||
<p v-if="passwordSuccess" class="pw-success">Password updated successfully.</p>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn-sm" @click="closePasswordDialog">Cancel</button>
|
||||
<button class="btn-primary btn-sm" :disabled="savingPassword" @click="submitPasswordChange">
|
||||
{{ savingPassword ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -373,6 +406,45 @@ async function saveTimezone() {
|
||||
setTimeout(() => { tzSaved.value = false }, 2000)
|
||||
}
|
||||
|
||||
// Settings — Password
|
||||
const showPasswordDialog = ref(false)
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const savingPassword = ref(false)
|
||||
const passwordError = ref('')
|
||||
const passwordSuccess = ref(false)
|
||||
|
||||
function closePasswordDialog() {
|
||||
showPasswordDialog.value = false
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
passwordError.value = ''
|
||||
passwordSuccess.value = false
|
||||
}
|
||||
|
||||
async function submitPasswordChange() {
|
||||
if (!currentPassword.value || !newPassword.value.trim()) {
|
||||
passwordError.value = 'Both fields are required'
|
||||
return
|
||||
}
|
||||
savingPassword.value = true
|
||||
passwordError.value = ''
|
||||
passwordSuccess.value = false
|
||||
try {
|
||||
await api.post('/api/auth/change-password', {
|
||||
current_password: currentPassword.value,
|
||||
new_password: newPassword.value,
|
||||
})
|
||||
passwordSuccess.value = true
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
} catch (e) {
|
||||
passwordError.value = e?.response?.data?.detail || 'Failed to update password'
|
||||
} finally {
|
||||
savingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Children
|
||||
const showChildForm = ref(false)
|
||||
const newChild = ref({ name: '', color: '#4F46E5' })
|
||||
@@ -836,6 +908,7 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin
|
||||
.settings-label { flex: 1; min-width: 180px; }
|
||||
.settings-title { font-size: 0.95rem; font-weight: 500; margin-bottom: 0.2rem; }
|
||||
.settings-hint { font-size: 0.8rem; color: #64748b; }
|
||||
.settings-divider { border-top: 1px solid #334155; margin: 1rem 0; }
|
||||
.settings-control { display: flex; align-items: center; gap: 0.75rem; flex-shrink: 0; }
|
||||
.tz-select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
@@ -895,4 +968,40 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin
|
||||
white-space: nowrap;
|
||||
}
|
||||
.break-check-label input[type="checkbox"] { cursor: pointer; }
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.dialog {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
width: 360px;
|
||||
max-width: 90vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.dialog h2 { font-size: 1.1rem; font-weight: 700; margin: 0; }
|
||||
.field { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.field label { font-size: 0.8rem; color: #94a3b8; }
|
||||
.field input {
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.field input:focus { outline: none; border-color: #818cf8; }
|
||||
.dialog-actions { display: flex; gap: 0.75rem; justify-content: flex-end; }
|
||||
.pw-error { color: #f87171; font-size: 0.85rem; margin: 0; }
|
||||
.pw-success { color: #4ade80; font-size: 0.85rem; margin: 0; }
|
||||
</style>
|
||||
|
||||
@@ -74,28 +74,31 @@
|
||||
</div>
|
||||
|
||||
<div class="session-actions">
|
||||
<div class="session-actions-left">
|
||||
<button
|
||||
class="btn-sm"
|
||||
v-if="scheduleStore.session.current_block_id && !scheduleStore.isPaused"
|
||||
@click="sendAction('pause')"
|
||||
>Pause</button>
|
||||
<button
|
||||
class="btn-sm btn-start"
|
||||
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset === 0 && scheduleStore.session.current_block_id"
|
||||
@click="scheduleStore.startCurrentBlock(scheduleStore.session.id)"
|
||||
>Start</button>
|
||||
<button
|
||||
class="btn-sm"
|
||||
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset > 0"
|
||||
@click="scheduleStore.resumeCurrentBlock(scheduleStore.session.id)"
|
||||
>Resume</button>
|
||||
<button
|
||||
class="btn-sm"
|
||||
v-if="scheduleStore.session.current_block_id"
|
||||
@click="scheduleStore.resetCurrentBlock(scheduleStore.session.id)"
|
||||
>Reset</button>
|
||||
</div>
|
||||
<button
|
||||
class="btn-sm"
|
||||
v-if="scheduleStore.session.current_block_id && !scheduleStore.isPaused"
|
||||
@click="sendAction('pause')"
|
||||
>Pause</button>
|
||||
<button
|
||||
class="btn-sm btn-start"
|
||||
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset === 0 && scheduleStore.session.current_block_id"
|
||||
@click="scheduleStore.startCurrentBlock(scheduleStore.session.id)"
|
||||
>Start</button>
|
||||
<button
|
||||
class="btn-sm"
|
||||
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset > 0"
|
||||
@click="scheduleStore.resumeCurrentBlock(scheduleStore.session.id)"
|
||||
>Resume</button>
|
||||
<button
|
||||
class="btn-sm"
|
||||
v-if="scheduleStore.session.current_block_id"
|
||||
@click="scheduleStore.resetCurrentBlock(scheduleStore.session.id)"
|
||||
>Reset</button>
|
||||
<button
|
||||
class="btn-sm"
|
||||
v-if="scheduleStore.session.current_block_id"
|
||||
@click="scheduleStore.markBlockDone(scheduleStore.session.id)"
|
||||
>Done</button>
|
||||
<button class="btn-sm btn-danger" @click="sendAction('complete')">End Day</button>
|
||||
</div>
|
||||
|
||||
@@ -419,11 +422,11 @@ h1 { font-size: 1.75rem; font-weight: 700; }
|
||||
}
|
||||
|
||||
.current-block-timer { display: flex; justify-content: center; margin: 1rem 0; }
|
||||
.session-actions { display: flex; align-items: center; justify-content: space-between; margin-top: 1rem; gap: 0.5rem; }
|
||||
.session-actions-left { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.session-actions { display: flex; align-items: center; margin-top: 1rem; gap: 0.5rem; }
|
||||
.session-actions .btn-sm { flex: 1; text-align: center; }
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.4rem 0.9rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border: 1px solid #334155;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
|
||||
@@ -60,6 +60,11 @@
|
||||
<option value="resume">↺ Resumed</option>
|
||||
<option value="complete">✓ Completed</option>
|
||||
<option value="skip">⟶ Skipped</option>
|
||||
<option value="reset">↩ Reset</option>
|
||||
<option value="break_start">☕ Break started</option>
|
||||
<option value="break_pause">⏸ Break paused</option>
|
||||
<option value="break_resume">↺ Break resumed</option>
|
||||
<option value="break_reset">↩ Break reset</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="edit-field">
|
||||
@@ -199,6 +204,11 @@ const EVENT_META = {
|
||||
resume: { icon: '↺', label: 'Resumed' },
|
||||
complete: { icon: '✓', label: 'Completed' },
|
||||
skip: { icon: '⟶', label: 'Skipped' },
|
||||
reset: { icon: '↩', label: 'Reset' },
|
||||
break_start: { icon: '☕', label: 'Break started' },
|
||||
break_pause: { icon: '⏸', label: 'Break paused' },
|
||||
break_resume: { icon: '↺', label: 'Break resumed' },
|
||||
break_reset: { icon: '↩', label: 'Break reset' },
|
||||
}
|
||||
|
||||
function eventIcon(entry) {
|
||||
@@ -217,6 +227,7 @@ function eventLabel(entry) {
|
||||
: `Strike removed (${entry.new_strikes}/3)`
|
||||
}
|
||||
if (entry.event_type === 'complete' && !entry.block_label) return 'Day ended'
|
||||
if (entry.event_type === 'complete' && entry.block_label) return `Marked Done by User — ${entry.block_label}`
|
||||
const action = EVENT_META[entry.event_type]?.label || entry.event_type
|
||||
return entry.block_label ? `${action} — ${entry.block_label}` : action
|
||||
}
|
||||
|
||||
@@ -19,15 +19,69 @@
|
||||
{{ u.is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
<span class="joined">Joined {{ formatDate(u.created_at) }}</span>
|
||||
<span class="last-active">
|
||||
{{ u.last_active_at ? 'Last active ' + formatDate(u.last_active_at, u.timezone) : 'Never logged in' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="enter-btn" :disabled="entering === u.id" @click="enter(u.id)">
|
||||
{{ entering === u.id ? 'Entering…' : 'Enter as User' }}
|
||||
</button>
|
||||
<div class="card-actions">
|
||||
<button class="enter-btn" :disabled="entering === u.id || !u.is_active" @click="enter(u.id)">
|
||||
{{ entering === u.id ? 'Entering…' : 'Enter as User' }}
|
||||
</button>
|
||||
<button class="reset-btn" @click="openReset(u)">Reset Password</button>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="u.is_active ? 'toggle-disable' : 'toggle-enable'"
|
||||
:disabled="toggling === u.id"
|
||||
@click="toggleActive(u)"
|
||||
>{{ toggling === u.id ? '…' : u.is_active ? 'Disable' : 'Enable' }}</button>
|
||||
<button class="delete-btn" @click="openDelete(u)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<div class="dialog-overlay" v-if="deleteTarget" @click.self="deleteTarget = null">
|
||||
<div class="dialog">
|
||||
<h2>Delete User</h2>
|
||||
<p class="dialog-user">{{ deleteTarget.full_name || deleteTarget.email }}</p>
|
||||
<p class="delete-warning">This will permanently delete the user and all associated data — children, schedules, sessions, activity logs, and subjects. This cannot be undone.</p>
|
||||
<p v-if="deleteError" class="reset-error">{{ deleteError }}</p>
|
||||
<div class="dialog-actions">
|
||||
<button class="cancel-btn" @click="deleteTarget = null">Cancel</button>
|
||||
<button class="delete-confirm-btn" :disabled="deleting" @click="submitDelete">
|
||||
{{ deleting ? 'Deleting…' : 'Delete Permanently' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset Password Dialog -->
|
||||
<div class="dialog-overlay" v-if="resetTarget" @click.self="closeReset">
|
||||
<div class="dialog">
|
||||
<h2>Reset Password</h2>
|
||||
<p class="dialog-user">{{ resetTarget.full_name || resetTarget.email }}</p>
|
||||
<div class="field">
|
||||
<label>New Password</label>
|
||||
<input
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
@keyup.enter="submitReset"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="resetError" class="reset-error">{{ resetError }}</p>
|
||||
<p v-if="resetSuccess" class="reset-success">Password updated successfully.</p>
|
||||
<div class="dialog-actions">
|
||||
<button class="cancel-btn" @click="closeReset">Cancel</button>
|
||||
<button class="confirm-btn" :disabled="resetting" @click="submitReset">
|
||||
{{ resetting ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -43,6 +97,82 @@ const users = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const entering = ref(null)
|
||||
const toggling = ref(null)
|
||||
|
||||
async function toggleActive(user) {
|
||||
toggling.value = user.id
|
||||
try {
|
||||
const res = await axios.post(`/api/admin/toggle-active/${user.id}`, {}, {
|
||||
headers: { Authorization: `Bearer ${superAdmin.adminToken}` },
|
||||
})
|
||||
user.is_active = res.data.is_active
|
||||
} catch {
|
||||
error.value = 'Failed to update user status'
|
||||
} finally {
|
||||
toggling.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTarget = ref(null)
|
||||
const deleting = ref(false)
|
||||
const deleteError = ref('')
|
||||
|
||||
function openDelete(user) {
|
||||
deleteTarget.value = user
|
||||
deleteError.value = ''
|
||||
}
|
||||
|
||||
async function submitDelete() {
|
||||
deleting.value = true
|
||||
deleteError.value = ''
|
||||
try {
|
||||
await axios.delete(`/api/admin/users/${deleteTarget.value.id}`, {
|
||||
headers: { Authorization: `Bearer ${superAdmin.adminToken}` },
|
||||
})
|
||||
users.value = users.value.filter(u => u.id !== deleteTarget.value.id)
|
||||
deleteTarget.value = null
|
||||
} catch {
|
||||
deleteError.value = 'Failed to delete user'
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetTarget = ref(null)
|
||||
const newPassword = ref('')
|
||||
const resetting = ref(false)
|
||||
const resetError = ref('')
|
||||
const resetSuccess = ref(false)
|
||||
|
||||
function openReset(user) {
|
||||
resetTarget.value = user
|
||||
newPassword.value = ''
|
||||
resetError.value = ''
|
||||
resetSuccess.value = false
|
||||
}
|
||||
|
||||
function closeReset() {
|
||||
resetTarget.value = null
|
||||
}
|
||||
|
||||
async function submitReset() {
|
||||
if (!newPassword.value.trim()) { resetError.value = 'Password cannot be empty'; return }
|
||||
resetting.value = true
|
||||
resetError.value = ''
|
||||
resetSuccess.value = false
|
||||
try {
|
||||
await axios.post(`/api/admin/reset-password/${resetTarget.value.id}`,
|
||||
{ new_password: newPassword.value },
|
||||
{ headers: { Authorization: `Bearer ${superAdmin.adminToken}` } }
|
||||
)
|
||||
resetSuccess.value = true
|
||||
newPassword.value = ''
|
||||
} catch {
|
||||
resetError.value = 'Failed to reset password'
|
||||
} finally {
|
||||
resetting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -73,9 +203,11 @@ function handleLogout() {
|
||||
router.push('/super-admin/login')
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
function formatDate(iso, timezone) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
const opts = { year: 'numeric', month: 'short', day: 'numeric' }
|
||||
if (timezone) opts.timeZone = timezone
|
||||
return new Date(iso).toLocaleDateString(undefined, opts)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -192,6 +324,49 @@ h2 {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.last-active {
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
padding: 0.45rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid #334155;
|
||||
color: #94a3b8;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
border-color: #818cf8;
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 0.45rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
.toggle-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.toggle-disable { border-color: #7f1d1d; color: #fca5a5; }
|
||||
.toggle-disable:hover:not(:disabled) { background: #7f1d1d; }
|
||||
.toggle-enable { border-color: #14532d; color: #4ade80; }
|
||||
.toggle-enable:hover:not(:disabled) { background: #14532d; }
|
||||
|
||||
.enter-btn {
|
||||
padding: 0.45rem 1rem;
|
||||
background: #f59e0b;
|
||||
@@ -208,4 +383,131 @@ h2 {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
padding: 0.45rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid #7f1d1d;
|
||||
color: #fca5a5;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
.delete-btn:hover { background: #7f1d1d; }
|
||||
|
||||
.delete-warning {
|
||||
font-size: 0.875rem;
|
||||
color: #fca5a5;
|
||||
background: #450a0a;
|
||||
border: 1px solid #7f1d1d;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.delete-confirm-btn {
|
||||
padding: 0.45rem 1rem;
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
font-weight: 600;
|
||||
border: 1px solid #991b1b;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.delete-confirm-btn:hover:not(:disabled) { background: #991b1b; }
|
||||
.delete-confirm-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 10px;
|
||||
padding: 2rem;
|
||||
width: 360px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.dialog h2 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.dialog-user {
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
outline: none;
|
||||
border-color: #818cf8;
|
||||
}
|
||||
|
||||
.reset-error { color: #f87171; font-size: 0.85rem; margin-top: 0.75rem; }
|
||||
.reset-success { color: #4ade80; font-size: 0.85rem; margin-top: 0.75rem; }
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
padding: 0.45rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid #334155;
|
||||
color: #94a3b8;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.cancel-btn:hover { border-color: #94a3b8; }
|
||||
|
||||
.confirm-btn {
|
||||
padding: 0.45rem 1rem;
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.confirm-btn:hover { background: #4338ca; }
|
||||
.confirm-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user