Files
BarangaySystem/resources/js/Pages/ManageAccounts.vue
2026-06-06 18:43:00 +08:00

656 lines
25 KiB
Vue

<template>
<div class="manage-accounts min-vh-100 bg-light pb-5">
<header class="header-premium text-white py-4 shadow-sm position-relative overflow-hidden mb-4 bg-primary-gradient">
<div class="container position-relative z-2">
<div class="d-flex align-items-center gap-4">
<div class="bg-white rounded-circle p-3 shadow">
<i class="fas fa-sitemap fa-2x text-primary"></i>
</div>
<div>
<h2 class="fw-bold text-white mb-0">Manage Accounts</h2>
<p class="text-white-50 small text-uppercase ls-wide mt-1">Chart of Accounts</p>
</div>
</div>
</div>
</header>
<div class="container">
<!-- Store selector (store owner / store manager only) -->
<div v-if="stores.length > 0" class="mb-3">
<div class="d-flex align-items-center gap-2 flex-wrap">
<label class="fw-semibold small text-muted mb-0">Store:</label>
<div class="d-flex gap-2 flex-wrap">
<button
v-for="s in stores"
:key="s.hashkey"
class="btn btn-sm"
:class="selectedStoreId === s.id ? 'btn-primary' : 'btn-outline-secondary'"
@click="selectStore(s)"
>
<i class="fas fa-store me-1"></i>{{ s.name }}
</button>
</div>
</div>
</div>
<!-- Accounting Theme Panel (Big 3 only) -->
<div v-if="isBig3" class="card border-0 shadow-sm rounded-4 bg-white mb-3">
<div
class="card-header bg-white border-0 d-flex align-items-center justify-content-between py-3 px-3"
style="cursor:pointer"
@click="themeOpen = !themeOpen"
>
<div class="d-flex align-items-center gap-2">
<i class="fas fa-palette text-primary"></i>
<span class="fw-bold small">Accounting Theme</span>
<span v-if="themeInfo" class="badge bg-primary-subtle text-primary small ms-1">{{ themeInfo.definition.label }}</span>
</div>
<i class="fas text-muted" :class="themeOpen ? 'fa-chevron-up' : 'fa-chevron-down'"></i>
</div>
<div v-if="themeOpen" class="card-body pt-0 px-3 pb-3">
<div v-if="loadingThemes" class="text-center py-3 text-muted small">
<i class="fas fa-circle-notch fa-spin me-1"></i> Loading themes
</div>
<div v-else-if="themeInfo">
<div class="row g-2 mb-3">
<div v-for="opt in themeInfo.options" :key="opt.key" class="col-6 col-md-4 col-lg-3">
<div
class="p-2 rounded-3 border h-100"
:class="opt.key === themeInfo.current ? 'border-primary border-2 bg-primary-subtle' : (opt.key === selectThemeKey ? 'border-primary' : 'border-light-subtle bg-light')"
style="cursor:pointer; font-size:.82rem"
@click="selectThemeKey = opt.key"
>
<div class="d-flex align-items-center gap-1 mb-1">
<i class="fas fa-palette text-primary" style="font-size:.7rem"></i>
<span class="fw-bold">{{ opt.label }}</span>
<span v-if="opt.key === themeInfo.current" class="badge bg-primary ms-auto" style="font-size:.6rem">Active</span>
</div>
<div class="text-muted" style="font-size:.72rem; line-height:1.3">{{ opt.description }}</div>
</div>
</div>
</div>
<div class="d-flex flex-wrap align-items-center gap-3 p-2 bg-light rounded-3 small">
<span class="text-muted">Theme accounts: <strong>{{ themeInfo.counts.theme_tagged }}</strong></span>
<span class="text-muted">User-added: <strong>{{ themeInfo.counts.user_added }}</strong></span>
<span v-if="themeDrift && themeDrift.totals.missing > 0" class="text-warning">
<i class="fas fa-exclamation-triangle me-1"></i>Missing: <strong>{{ themeDrift.totals.missing }}</strong>
</span>
<div class="ms-auto d-flex gap-2">
<button
class="btn btn-outline-primary btn-sm"
:disabled="!selectThemeKey || selectThemeKey === themeInfo.current || switchingTheme"
@click="switchTheme"
>
<i class="fas fa-shuffle me-1"></i>{{ switchingTheme ? 'Switching…' : 'Switch' }}
</button>
<button
class="btn btn-primary btn-sm"
:disabled="applyingTheme"
@click="reapplyTheme"
>
<i class="fas fa-sync me-1" :class="{ 'fa-spin': applyingTheme }"></i>
{{ applyingTheme ? 'Applying…' : 'Apply theme' }}
</button>
</div>
</div>
<p class="text-muted mt-2 mb-0" style="font-size:.75rem">
<i class="fas fa-info-circle me-1"></i>
Switching only changes which theme is tracked it does not seed accounts. Apply is additive: never deletes or renames.
</p>
</div>
</div>
</div>
<!-- Toolbar -->
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<h4 class="fw-bold mb-0"><i class="fas fa-folder-tree text-primary me-2"></i> Hierarchy</h4>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-outline-secondary btn-sm" @click="includeArchived = !includeArchived">
<i class="fas" :class="includeArchived ? 'fa-eye-slash' : 'fa-eye'"></i>
{{ includeArchived ? 'Hide archived' : 'Show archived' }}
</button>
<button class="btn btn-primary btn-sm" @click="openCreate(null)">
<i class="fas fa-plus me-1"></i> Add root account
</button>
</div>
</div>
<div class="card border-0 shadow-lg rounded-4 bg-white">
<div class="card-body p-3">
<div v-if="loading" class="text-center text-muted py-4">
<i class="fas fa-circle-notch fa-spin me-2"></i> Loading
</div>
<div v-else-if="visibleTree.length === 0" class="text-center text-muted py-5">
<i class="fas fa-inbox fa-2x mb-2 d-block"></i>
No accounts yet. Click <strong>Add root account</strong> to get started.
</div>
<ul v-else class="list-unstyled mb-0">
<AccountNode
v-for="node in visibleTree"
:key="node.id"
:node="node"
:include-archived="includeArchived"
@add-child="openCreate"
@edit="openEdit"
@move="openMove"
@archive="archive"
@restore="restore"
/>
</ul>
</div>
</div>
</div>
<!-- Edit / Create modal -->
<Teleport to="body">
<div v-if="modal.open" class="modal-overlay" @click.self="closeModal">
<div class="modal-card card border-0 shadow-lg rounded-4">
<div class="card-body p-4">
<h5 class="fw-bold mb-3">
{{ modal.mode === 'create' ? 'Add account' : 'Edit account' }}
</h5>
<div v-if="modal.parentName" class="small text-muted mb-3">
Under: <strong>{{ modal.parentName }}</strong>
</div>
<!-- Inline error visible even when this modal is open -->
<div v-if="modal.error" class="alert alert-danger py-2 small mb-3">
<i class="fas fa-exclamation-triangle me-1"></i>{{ modal.error }}
</div>
<div class="mb-3">
<label class="form-label small fw-bold">Name</label>
<input v-model="form.name" type="text" class="form-control" placeholder="Account name" />
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label small fw-bold">Type</label>
<select v-model="form.type" class="form-select">
<option value="REVENUE">REVENUE</option>
<option value="EXPENSE">EXPENSE</option>
<option value="ASSET">ASSET</option>
<option value="LIABILITY">LIABILITY</option>
<option value="EQUITY">EQUITY</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label small fw-bold">Default flow</label>
<select v-model="form.default_flow" class="form-select">
<option value="INCOME">INCOME</option>
<option value="EXPENSE">EXPENSE</option>
</select>
</div>
</div>
<div class="mt-3">
<label class="form-label small fw-bold">Description <span class="text-muted fw-normal">(optional)</span></label>
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
</div>
<div class="d-flex justify-content-end gap-2 mt-4">
<button class="btn btn-outline-secondary" @click="closeModal" :disabled="saving">Cancel</button>
<button class="btn btn-primary" @click="save" :disabled="saving || !form.name.trim()">
<i class="fas fa-save me-1"></i>
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
<!-- Move modal -->
<Teleport to="body">
<div v-if="moveModal.open" class="modal-overlay" @click.self="closeMoveModal">
<div class="modal-card card border-0 shadow-lg rounded-4">
<div class="card-body p-4">
<h5 class="fw-bold mb-3">Move account</h5>
<div class="small text-muted mb-3">
Moving: <strong>{{ moveModal.node?.name }}</strong>
</div>
<div v-if="moveModal.error" class="alert alert-danger py-2 small mb-3">
<i class="fas fa-exclamation-triangle me-1"></i>{{ moveModal.error }}
</div>
<label class="form-label small fw-bold">New parent</label>
<select v-model="moveModal.targetParentId" class="form-select">
<option :value="null"> Root (no parent) </option>
<option v-for="cand in moveCandidates" :key="cand.id" :value="cand.id">
{{ cand.indent }}{{ cand.name }}
</option>
</select>
<div class="small text-muted mt-2">
<i class="fas fa-info-circle me-1"></i>
The account being moved and its descendants are excluded to prevent cycles.
</div>
<div class="d-flex justify-content-end gap-2 mt-4">
<button class="btn btn-outline-secondary" @click="closeMoveModal" :disabled="moving">Cancel</button>
<button class="btn btn-primary" @click="confirmMove" :disabled="moving">
<i class="fas fa-arrow-right me-1"></i>
{{ moving ? 'Moving…' : 'Move' }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
<!-- Notice modal (for archive/restore/global errors outside an open modal) -->
<BaseModal
v-model="notice.open"
:modalTitle="notice.title"
:footerClose="true"
>
<div class="d-flex align-items-start gap-3 py-2">
<div class="icon-circle flex-shrink-0" :class="noticeIconBg">
<i class="fas" :class="noticeIcon"></i>
</div>
<div class="pt-1" style="white-space: pre-wrap;">{{ notice.message }}</div>
</div>
</BaseModal>
<!-- Confirm modal -->
<ConfirmModal
v-model="confirmState.open"
:title="confirmState.title"
:message="confirmState.message"
:confirmText="confirmState.confirmText"
:cancelText="confirmState.cancelText"
:variant="confirmState.variant"
@confirm="onConfirmAccept"
@cancel="onConfirmReject"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import axios from 'axios';
import BaseModal from '@/Components/Core/BaseModal.vue';
import ConfirmModal from '@/Components/Core/ConfirmModal.vue';
import { useAuth } from '@/composables/Core/useAuth';
const { isUltimate, isSuperOperator, isOperator } = useAuth();
const isBig3 = computed(() => isUltimate.value || isSuperOperator.value || isOperator.value);
// Recursive node component
const AccountNode = {
name: 'AccountNode',
props: { node: Object, includeArchived: Boolean },
emits: ['add-child', 'edit', 'move', 'archive', 'restore'],
setup(props, { emit }) {
const expanded = ref(true);
const children = computed(() => {
const list = props.node.children || [];
return props.includeArchived ? list : list.filter(c => c.is_active);
});
const hasChildren = computed(() => children.value.length > 0);
return { expanded, children, hasChildren };
},
template: `
<li class="account-node mb-1">
<div class="d-flex align-items-center gap-2 py-2 px-2 rounded hover-bg" :class="{ 'opacity-50': !node.is_active }">
<button class="btn btn-sm btn-link p-0 toggle-btn" v-if="hasChildren" @click="expanded = !expanded">
<i class="fas" :class="expanded ? 'fa-chevron-down' : 'fa-chevron-right'"></i>
</button>
<span v-else class="toggle-spacer"></span>
<span class="badge" :class="node.default_flow === 'INCOME' ? 'bg-success-subtle text-success' : 'bg-danger-subtle text-danger'">
{{ node.default_flow || node.type }}
</span>
<span class="fw-semibold">{{ node.name }}</span>
<span v-if="node.theme_key" class="badge bg-light text-muted small" :title="'theme: ' + node.theme_key">
<i class="fas fa-palette"></i>
</span>
<span v-if="!node.is_active" class="badge bg-secondary small">archived</span>
<div class="ms-auto d-flex gap-1">
<button class="btn btn-sm btn-outline-primary" @click="$emit('add-child', node)" title="Add child">
<i class="fas fa-plus"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" @click="$emit('edit', node)" title="Edit">
<i class="fas fa-pen"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" @click="$emit('move', node)" title="Move">
<i class="fas fa-arrows-up-down-left-right"></i>
</button>
<button v-if="node.is_active" class="btn btn-sm btn-outline-danger" @click="$emit('archive', node)" title="Archive">
<i class="fas fa-archive"></i>
</button>
<button v-else class="btn btn-sm btn-outline-success" @click="$emit('restore', node)" title="Restore">
<i class="fas fa-undo"></i>
</button>
</div>
</div>
<ul v-if="hasChildren && expanded" class="list-unstyled ms-4 ps-3 border-start">
<account-node
v-for="child in children"
:key="child.id"
:node="child"
:include-archived="includeArchived"
@add-child="(n) => $emit('add-child', n)"
@edit="(n) => $emit('edit', n)"
@move="(n) => $emit('move', n)"
@archive="(n) => $emit('archive', n)"
@restore="(n) => $emit('restore', n)"
/>
</ul>
</li>
`,
};
const tree = ref([]);
const loading = ref(false);
const includeArchived = ref(false);
const saving = ref(false);
// Store selector (store-level users only)
const stores = ref([]);
const selectedStoreId = ref(null);
// Theme management (Big 3 only)
const themeOpen = ref(false);
const themeInfo = ref(null);
const themeDrift = ref(null);
const loadingThemes = ref(false);
const selectThemeKey = ref(null);
const switchingTheme = ref(false);
const applyingTheme = ref(false);
const modal = ref({ open: false, mode: 'create', parentName: null, error: null });
const form = ref(emptyForm());
const moveModal = ref({ open: false, node: null, targetParentId: null, error: null });
const moving = ref(false);
// Notice (for errors that happen outside an open modal: archive, restore)
const notice = ref({ open: false, variant: 'info', title: 'Notice', message: '' });
const noticeIcon = computed(() => {
if (notice.value.variant === 'danger') return 'fa-exclamation-triangle text-danger';
if (notice.value.variant === 'warning') return 'fa-exclamation-circle text-warning';
if (notice.value.variant === 'success') return 'fa-check-circle text-success';
return 'fa-info-circle text-primary';
});
const noticeIconBg = computed(() => {
if (notice.value.variant === 'danger') return 'bg-soft-danger';
if (notice.value.variant === 'warning') return 'bg-soft-warning';
if (notice.value.variant === 'success') return 'bg-soft-success';
return 'bg-soft-primary';
});
function showNotice(message, { variant = 'info', title } = {}) {
notice.value = {
open: true, variant,
title: title ?? (variant === 'danger' ? 'Error' : variant === 'success' ? 'Success' : 'Notice'),
message: String(message ?? ''),
};
}
// Confirm modal
const confirmState = ref({ open: false, title: 'Confirm Action', message: '', confirmText: 'Confirm', cancelText: 'Cancel', variant: 'info' });
let confirmResolver = null;
function askConfirm(message, opts = {}) {
confirmState.value = { open: true, title: opts.title ?? 'Confirm Action', message, confirmText: opts.confirmText ?? 'Confirm', cancelText: opts.cancelText ?? 'Cancel', variant: opts.variant ?? 'info' };
return new Promise((resolve) => { confirmResolver = resolve; });
}
function onConfirmAccept() { if (confirmResolver) { confirmResolver(true); confirmResolver = null; } }
function onConfirmReject() { if (confirmResolver) { confirmResolver(false); confirmResolver = null; } }
const moveCandidates = computed(() => {
if (!moveModal.value.node) return [];
const excludeId = moveModal.value.node.id;
const list = [];
const walk = (nodes, depth) => {
for (const n of nodes) {
if (n.id === excludeId) continue;
if (!n.is_active) continue;
list.push({ id: n.id, name: n.name, indent: ' '.repeat(depth) });
if (n.children?.length) walk(n.children, depth + 1);
}
};
walk(tree.value, 0);
return list;
});
function emptyForm() {
return { id: null, parent_id: null, name: '', type: 'REVENUE', default_flow: 'INCOME', description: '' };
}
const visibleTree = computed(() =>
includeArchived.value ? tree.value : tree.value.filter(n => n.is_active)
);
onMounted(async () => {
if (!isBig3.value) {
await fetchStores();
} else {
loadThemes();
}
fetchAll();
});
async function loadThemes() {
loadingThemes.value = true;
try {
const [infoRes, driftRes] = await Promise.all([
axios.post('/admin/accounting/theme', {}),
axios.post('/admin/accounting/theme/drift', {}),
]);
themeInfo.value = infoRes.data ?? null;
themeDrift.value = driftRes.data?.data ?? null;
if (themeInfo.value?.current && !selectThemeKey.value) {
selectThemeKey.value = themeInfo.value.current;
}
} catch (e) {
console.error('Failed to load themes', e);
} finally {
loadingThemes.value = false;
}
}
async function switchTheme() {
if (!selectThemeKey.value || selectThemeKey.value === themeInfo.value?.current) return;
switchingTheme.value = true;
try {
const res = await axios.post('/admin/accounting/theme/set', { key: selectThemeKey.value });
if (res.data?.success) {
await loadThemes();
await fetchAll();
await reapplyTheme();
} else {
showNotice(res.data?.message || 'Could not switch theme.', { variant: 'danger' });
}
} catch (e) {
showNotice(e.response?.data?.message || 'Could not switch theme.', { variant: 'danger' });
} finally {
switchingTheme.value = false;
}
}
async function reapplyTheme() {
applyingTheme.value = true;
try {
const res = await axios.post('/admin/accounting/theme/apply', {});
if (res.data?.success) {
showNotice(res.data.message || 'Theme applied.', { variant: 'success', title: 'Theme Applied' });
await loadThemes();
await fetchAll();
} else {
showNotice(res.data?.message || 'Apply failed.', { variant: 'danger' });
}
} catch (e) {
showNotice(e.response?.data?.message || 'Apply failed.', { variant: 'danger' });
} finally {
applyingTheme.value = false;
}
}
async function fetchStores() {
try {
const res = await axios.post('/Admin/Stores/Selectable', {});
stores.value = res.data?.data ?? [];
if (stores.value.length > 0) {
selectedStoreId.value = stores.value[0].id ?? null;
}
} catch (e) {
console.error('Failed to load stores', e);
}
}
function selectStore(s) {
selectedStoreId.value = s.id ?? null;
fetchAll();
}
async function fetchAll() {
loading.value = true;
try {
const treeRes = await axios.post('/admin/accounting/tree', {});
tree.value = treeRes.data?.data ?? [];
} catch (e) {
console.error('Failed to load accounts', e);
} finally {
loading.value = false;
}
}
function openCreate(parentNode) {
form.value = emptyForm();
if (parentNode) {
form.value.parent_id = parentNode.id;
form.value.type = parentNode.type || 'REVENUE';
form.value.default_flow = parentNode.default_flow || 'INCOME';
}
modal.value = { open: true, mode: 'create', parentName: parentNode?.name ?? null, error: null };
}
function openEdit(node) {
form.value = {
id: node.id,
parent_id: node.parent_id,
name: node.name,
type: node.type,
default_flow: node.default_flow || 'INCOME',
description: node.description || '',
};
modal.value = { open: true, mode: 'edit', parentName: null, error: null };
}
function closeModal() {
if (saving.value) return;
modal.value.open = false;
}
async function save() {
saving.value = true;
modal.value.error = null;
try {
const url = modal.value.mode === 'create'
? '/admin/accounting/accounts/create'
: '/admin/accounting/accounts/update';
const res = await axios.post(url, form.value);
if (!res.data?.success) {
modal.value.error = res.data?.message || 'Save failed.';
return;
}
modal.value.open = false;
await fetchAll();
} catch (e) {
// Show error inline so it's visible even while this modal is open
modal.value.error = e.response?.data?.message || 'Save failed. Please try again.';
} finally {
saving.value = false;
}
}
function openMove(node) {
moveModal.value = { open: true, node, targetParentId: node.parent_id ?? null, error: null };
}
function closeMoveModal() {
if (moving.value) return;
moveModal.value.open = false;
}
async function confirmMove() {
if (!moveModal.value.node) return;
moving.value = true;
moveModal.value.error = null;
try {
const res = await axios.post('/admin/accounting/accounts/move', {
id: moveModal.value.node.id,
parent_id: moveModal.value.targetParentId,
});
if (!res.data?.success) {
moveModal.value.error = res.data?.message || 'Move failed.';
return;
}
moveModal.value.open = false;
await fetchAll();
} catch (e) {
moveModal.value.error = e.response?.data?.message || 'Move failed.';
} finally {
moving.value = false;
}
}
async function archive(node) {
const ok = await askConfirm(
`Archive "${node.name}"? It will be hidden from new transactions.`,
{ title: 'Archive account', confirmText: 'Archive', variant: 'danger' }
);
if (!ok) return;
try {
const res = await axios.post('/admin/accounting/accounts/archive', { id: node.id });
if (!res.data?.success) { showNotice(res.data?.message || 'Archive failed.', { variant: 'danger' }); return; }
await fetchAll();
} catch (e) {
showNotice(e.response?.data?.message || 'Archive failed.', { variant: 'danger' });
}
}
async function restore(node) {
try {
const res = await axios.post('/admin/accounting/accounts/restore', { id: node.id });
if (!res.data?.success) { showNotice(res.data?.message || 'Restore failed.', { variant: 'danger' }); return; }
await fetchAll();
} catch (e) {
showNotice(e.response?.data?.message || 'Restore failed.', { variant: 'danger' });
}
}
</script>
<style scoped>
.bg-primary-gradient { background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%); }
.toggle-btn { width: 1.5rem; }
.toggle-spacer { display: inline-block; width: 1.5rem; }
.hover-bg:hover { background-color: rgba(13, 110, 253, 0.06); }
.account-node ul { margin-top: .25rem; }
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,.45);
z-index: 1080;
display: flex; align-items: center; justify-content: center;
padding: 1rem;
}
.modal-card { width: 100%; max-width: 520px; background: #fff; }
.ls-wide { letter-spacing: .08em; }
.icon-circle {
width: 44px; height: 44px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
}
.bg-soft-danger { background-color: rgba(231, 76, 60, 0.12); }
.bg-soft-warning { background-color: rgba(243, 156, 18, 0.12); }
.bg-soft-success { background-color: rgba(46, 204, 113, 0.12); }
.bg-soft-primary { background-color: rgba(52, 152, 219, 0.12); }
</style>