656 lines
25 KiB
Vue
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>
|