initial: bootstrap from BukidBountyApp base
This commit is contained in:
655
resources/js/Pages/ManageAccounts.vue
Normal file
655
resources/js/Pages/ManageAccounts.vue
Normal file
@@ -0,0 +1,655 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user