Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/components/AppSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const menuSections = [
},
{ label: "Phiếu nhập kho", to: "/stock-in", icon: "mdi-tray-arrow-down" },
{ label: "Phiếu xuất kho", to: "/stock-out", icon: "mdi-tray-arrow-up" },
{ label: "Kiểm kê kho", to: "/inventory-counts", icon: "mdi-clipboard-check-outline" },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 5 --glob '*.{js,vue}' \
  'canAccessRoute|beforeEach|inventory-counts|InventoryDetail|/inventory/:id|ADMIN|MANAGER|EMPLOYEE' \
  src

Repository: ThienlocTran/sme-stocksense-frontend

Length of output: 27112


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- permissionService.js ---'
cat -n src/services/permissionService.js

printf '%s\n' '--- router/index.js ---'
cat -n src/router/index.js | sed -n '1,90p'

printf '%s\n' '--- inventory-count related files ---'
git ls-files | rg '(^|/)(InventoryCount|inventoryCount|inventory-count|inventoryService|auth|permission)'
printf '%s\n' '--- route and capability references ---'
rg -n -C 4 --glob '*.{js,vue}' \
  'InventoryCount|inventory-count|inventoryCounts|can(Create|Cancel|Finalize|Access).*Inventory|finaliz|chốt|hủy|create.*count' \
  src

Repository: ThienlocTran/sme-stocksense-frontend

Length of output: 29688


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

permission = Path("src/services/permissionService.js").read_text()
route_paths = ["/inventory-counts", "/inventory/123"]
roles = ["ADMIN", "MANAGER", "EMPLOYEE"]

# Extract the current route-policy function and emulate its explicitly visible
# branches. This is read-only and does not execute repository code.
def can_access(path, role):
    if path in ("/employees", "/users", "/import-excel"):
        return role == "ADMIN"
    if path == "/partners":
        return role in {"ADMIN", "MANAGER"}
    if path in ("/approvals", "/pending-export-approvals", "/export-approvals"):
        return role in {"ADMIN", "MANAGER"}
    if re.fullmatch(r"/stock-in/[^/]+", path):
        return role in {"ADMIN", "EMPLOYEE"}
    if re.fullmatch(r"/stock-out/[^/]+", path):
        return role in {"ADMIN", "MANAGER", "EMPLOYEE"}
    if re.fullmatch(r"/stock-(in|out)/(?:create|[^/]+/edit)", path):
        return role in {"ADMIN", "EMPLOYEE"}
    return True

print("permission branches mention inventory-counts:", "/inventory-counts" in permission)
for path in route_paths:
    print(path, {role: can_access(path, role) for role in roles})
PY

Repository: ThienlocTran/sme-stocksense-frontend

Length of output: 361


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- InventoryCountList.vue ---'
cat -n src/views/InventoryCountList.vue | sed -n '1,380p'

printf '%s\n' '--- InventoryCountDetailView.vue ---'
cat -n src/views/InventoryCountDetailView.vue | sed -n '1,250p'

printf '%s\n' '--- InventoryCountCreateModal.vue ---'
cat -n src/components/InventoryCountCreateModal.vue | sed -n '1,320p'

printf '%s\n' '--- inventoryService.js ---'
cat -n src/services/inventoryService.js | sed -n '1,320p'

printf '%s\n' '--- inventory-count API references ---'
rg -n -C 3 --glob '*.{js,vue}' \
  'inventory-count|inventoryCount|InventoryCount|/counts|count.*inventory|inventory.*count' \
  src

Repository: ThienlocTran/sme-stocksense-frontend

Length of output: 48116


Áp dụng phân quyền kiểm kê kho nhất quán.

canAccessRoute thiếu rule cho /inventory-counts và /inventory/:id, nên nhánh mặc định cho phép mọi role truy cập. InventoryCountList.vue luôn hiển thị nút tạo; InventoryCountDetailView.vue luôn hiển thị nút hủy/chốt. Áp dụng capability theo role, trong đó EMPLOYEE tạo phiếu và MANAGER duyệt/chốt. Backend vẫn phải authorization các API tạo, hủy và chốt.

📍 Affects 2 files
  • src/components/AppSidebar.vue#L52-L52 (this comment)
  • src/router/index.js#L33-L34
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/AppSidebar.vue` at line 52, Áp dụng phân quyền kiểm kê kho
nhất quán tại src/components/AppSidebar.vue dòng 52 và src/router/index.js dòng
33-34: bổ sung rule canAccessRoute cho /inventory-counts và /inventory/:id thay
vì để nhánh mặc định cho phép mọi role, đồng thời giới hạn các route theo
capability tương ứng. Trong InventoryCountList.vue chỉ hiển thị thao tác tạo cho
EMPLOYEE; trong InventoryCountDetailView.vue chỉ cho phép MANAGER hủy/chốt; và
cập nhật authorization backend cho các API tạo, hủy, chốt theo đúng quyền này.

Source: Path instructions

],
},
{
Expand All @@ -69,12 +70,12 @@ const menuSections = [
{
title: "Hệ thống",
items: [
{ label: "Cảnh báo tồn kho", to: "/alerts", icon: "mdi-alert-outline" },
{
label: "Import Excel",
label: "Nhập liệu Excel",
to: "/import-excel",
icon: "mdi-file-excel-outline",
},
{ label: "Cảnh báo tồn kho", to: "/alerts", icon: "mdi-alert-outline" },
{
label: "Nhân viên",
to: "/employees",
Expand Down
246 changes: 246 additions & 0 deletions src/components/InventoryCountCreateModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
<template>
<!-- Backdrop (Mờ nền) -->
<div
v-if="isOpen"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-900/50 backdrop-blur-sm transition-opacity"
>
<!-- Modal Panel -->
<div
class="bg-white rounded-xl shadow-xl w-full max-w-lg overflow-hidden transform transition-all"
>
<!-- Header -->
<div class="px-6 py-4 border-b border-gray-100 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-800">Tạo đợt kiểm kê mới</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 transition-colors p-1.5 rounded-full hover:bg-gray-100"
:disabled="isSubmitting"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>

<div class="p-6">
<form @submit.prevent="handleSubmit">

<!-- Kho (Warehouse) - Bắt buộc -->
<div class="mb-5">
<label class="block text-sm font-medium text-gray-700 mb-1.5">
Kho kiểm kê <span class="text-red-500">*</span>
</label>
<select
v-model="formData.warehouseId"
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors bg-white"
:class="errors.warehouseId ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : 'border-gray-300 focus:border-blue-500'"
@change="errors.warehouseId = false"
>
<option value="" disabled>-- Chọn kho --</option>
<option v-for="wh in warehouses" :key="wh.id" :value="wh.id">
{{ wh.name }}
</option>
</select>
<!-- Thông báo lỗi validate -->
<p v-if="errors.warehouseId" class="text-red-500 text-xs mt-1.5 flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
Vui lòng chọn kho để kiểm kê.
</p>
</div>

<!-- Phạm vi kiểm kê (Scope) -->
<div class="mb-5">
<label class="block text-sm font-medium text-gray-700 mb-2">Phạm vi kiểm kê</label>
<div class="flex gap-6">
<label class="flex items-center cursor-pointer group">
<input
type="radio"
v-model="formData.scope"
value="ALL"
class="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
>
<span class="ml-2 text-sm text-gray-700 group-hover:text-blue-600 transition-colors">Kiểm kê toàn bộ</span>
</label>
<label class="flex items-center cursor-pointer group">
<input
type="radio"
v-model="formData.scope"
value="PARTIAL"
class="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
>
<span class="ml-2 text-sm text-gray-700 group-hover:text-blue-600 transition-colors">Theo sản phẩm cụ thể</span>
</label>
</div>
</div>

<!-- Danh sách sản phẩm (Chỉ hiện nếu chọn PARTIAL) -->
<div v-if="formData.scope === 'PARTIAL'" class="mb-5 bg-gray-50/80 p-4 rounded-lg border border-gray-200">
<label class="block text-sm font-medium text-gray-700 mb-3">
Chọn sản phẩm <span class="text-red-500">*</span>
</label>
<!-- Wrapper có thanh cuộn nếu danh sách dài -->
<div class="max-h-48 overflow-y-auto pr-2 custom-scrollbar">
<div
v-for="product in products"
:key="product.id"
class="flex items-center py-2 border-b border-gray-100 last:border-0 hover:bg-white transition-colors px-2 rounded"
>
<input
type="checkbox"
:id="`product-${product.id}`"
:value="product.id"
v-model="formData.productIds"
class="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500 cursor-pointer"
@change="errors.productIds = false"
>
<label :for="`product-${product.id}`" class="ml-3 text-sm text-gray-700 cursor-pointer flex-1 flex justify-between items-center">
<div class="flex flex-col">
<span class="font-medium">{{ product.sku }}</span>
<span class="text-gray-500 text-xs">{{ product.name }}</span>
</div>
<span class="text-gray-500 text-xs bg-gray-100 px-2 py-1 rounded">Tồn: {{ product.stock }}</span>
</label>
</div>
</div>
<!-- Thông báo lỗi validate -->
<p v-if="errors.productIds" class="text-red-500 text-xs mt-2 flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
Vui lòng chọn ít nhất một sản phẩm.
</p>
</div>

<!-- Ghi chú -->
<div class="mb-2">
<label class="block text-sm font-medium text-gray-700 mb-1.5">Ghi chú (Tùy chọn)</label>
<textarea
v-model="formData.note"
rows="2"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
placeholder="Nhập lý do kiểm kê..."
></textarea>
</div>

</form>
</div>

<!-- Footer Actions -->
<div class="px-6 py-4 border-t border-gray-100 bg-gray-50 flex justify-end gap-3 rounded-b-xl">
<button
type="button"
@click="closeModal"
class="px-5 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-200"
:disabled="isSubmitting"
>
Hủy bỏ
</button>

<!-- Nút Submit có Loading -->
<button
@click="handleSubmit"
class="px-5 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 flex items-center justify-center min-w-[130px]"
:disabled="isSubmitting"
>
<svg v-if="isSubmitting" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span v-if="!isSubmitting">Xác nhận tạo</span>
<span v-else>Đang xử lý...</span>
</button>
</div>
</div>
</div>
</template>

<script setup>
import { ref, reactive, onMounted } from 'vue'
import { getWarehouses } from '../services/warehouseService'
import { getProducts } from '../services/productService'

const props = defineProps({
isOpen: {
type: Boolean,
default: false
}
})

const emit = defineEmits(['close', 'submit'])

const warehouses = ref([])
const products = ref([])
const isLoading = ref(false)
const isSubmitting = ref(false)

const formData = reactive({
warehouseId: '',
scope: 'ALL',
productIds: [],
note: ''
})

const errors = reactive({
warehouseId: false,
productIds: false
})

const fetchInitialData = async () => {
isLoading.value = true
try {
const warehouseData = await getWarehouses({ status: 'HOAT_DONG' })
warehouses.value = warehouseData.content || warehouseData.data || warehouseData

const productData = await getProducts()
products.value = productData.content || productData.data || productData
Comment on lines +192 to +193

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Tải đủ sản phẩm cho phạm vi PARTIAL.

getProducts() dùng mặc định page = 0 và size = 10. Modal chỉ cho chọn 10 sản phẩm đầu tiên. Người dùng không thể tạo kiểm kê một phần cho sản phẩm ở các trang sau. Thêm tìm kiếm hoặc phân trang trước khi cho phép chọn sản phẩm.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/InventoryCountCreateModal.vue` around lines 192 - 193, Update
the product-loading flow in InventoryCountCreateModal so PARTIAL inventory
selection can access products beyond the first default page of getProducts().
Add product search or pagination to let users find and select products from
later pages, while preserving the existing product selection behavior.

} catch (error) {
console.error('Lỗi tải dữ liệu kho/sản phẩm:', error)
} finally {
isLoading.value = false
}
Comment on lines +186 to +198

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Hiển thị lỗi tải dữ liệu và chặn gửi khi dữ liệu chưa sẵn sàng.

fetchInitialData chỉ ghi lỗi vào console. Template cũng không dùng isLoading. Khi tải kho thất bại, modal chỉ hiện danh sách trống và người dùng không thể tạo phiếu mà không biết nguyên nhân. Lưu lỗi vào state, hiển thị error state, và khóa nút gửi cho đến khi tải kho hoàn tất.

As per path instructions: "src/components/**: Kiểm tra loading, empty state, error state, form validation..."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/InventoryCountCreateModal.vue` around lines 186 - 198, Cập
nhật fetchInitialData để lưu lỗi tải kho/sản phẩm vào state thay vì chỉ
console.error, đồng thời hiển thị loading/error/empty state tương ứng trong
template. Dùng isLoading và trạng thái lỗi để khóa nút gửi cho đến khi dữ liệu
kho tải thành công; giữ nguyên luồng hiển thị danh sách khi tải thành công.

Source: Path instructions

}

onMounted(() => {
fetchInitialData()
})

const closeModal = () => {
if (isSubmitting.value) return
formData.warehouseId = ''
formData.scope = 'ALL'
formData.productIds = []
formData.note = ''
errors.warehouseId = false
errors.productIds = false
emit('close')
}

const handleSubmit = async () => {
errors.warehouseId = !formData.warehouseId
errors.productIds = formData.scope === 'PARTIAL' && formData.productIds.length === 0

if (errors.warehouseId || errors.productIds) return

isSubmitting.value = true
try {
emit('submit', { ...formData })
Comment on lines +216 to +224

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Chuẩn hóa payload submit trước khi thêm vào danh sách.

Payload hiện chỉ có warehouseId, scope, productIds, và note. Nhưng src/views/InventoryCountList.vue dòng 153-160 cần id, code, warehouseName, status, createdDate, và createdBy. Bản ghi mới sẽ có dữ liệu trống và link chi tiết thành /inventory/undefined. Trả về bản ghi đã tạo từ API, hoặc map đầy đủ dữ liệu trước khi unshift.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/InventoryCountCreateModal.vue` around lines 216 - 224, Update
handleSubmit in InventoryCountCreateModal.vue so the submit payload is
normalized with the complete inventory-count record fields required by
InventoryCountList.vue, including id, code, warehouseName, status, createdDate,
and createdBy. Prefer returning the created record from the API and use that
result when unshifting into the list; otherwise map all required fields before
insertion so detail links never use an undefined id.

} finally {
isSubmitting.value = false
}
}
</script>

<style scoped>
/* CSS Custom Scrollbar cho danh sách sản phẩm (chỉ hiển thị trên trình duyệt Webkit) */
.custom-scrollbar::-webkit-scrollbar {
width: 5px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
</style>
4 changes: 3 additions & 1 deletion src/router/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import ApprovalsView from '../views/ApprovalsView.vue'
import PendingExportApprovalsView from '../views/PendingExportApprovalsView.vue'
import ImportExcelView from '../views/ImportExcelView.vue'
import AlertsView from '../views/AlertsView.vue'
import InventoryCountList from '../views/InventoryCountList.vue'
import UsersView from '../views/UsersView.vue'
import EmployeeListView from '../views/employees/EmployeeListView.vue'
import CategoryListView from '../views/categories/CategoryListView.vue'
Expand All @@ -29,6 +30,8 @@ const routes = [
{ path: '/warehouses', component: WarehousesView, meta: { title: 'Kho hàng' } },
{ path: '/inventory', component: InventoryView, meta: { title: 'Tồn kho' } },
{ path: '/inventory-transactions', component: InventoryTransactionsView, meta: { title: 'Lịch sử giao dịch' } },
{ path: '/inventory-counts', component: InventoryCountList, meta: { title: 'Kiểm kê kho' } },
{ path: '/inventory/:id', name: 'InventoryDetail', component: () => import('../views/InventoryCountDetailView.vue') },
{ path: '/stock-in', component: StockDocumentsView, props: { type: 'in' }, meta: { title: 'Phiếu nhập kho' } },
{ path: '/stock-in/create', component: StockDocumentCreateView, props: { type: 'in' }, meta: { title: 'Tạo phiếu nhập kho' } },
{ path: '/stock-in/:id/edit', component: StockDocumentCreateView, props: route => ({ id: route.params.id, type: 'in', mode: 'edit' }), meta: { title: 'Chỉnh sửa phiếu nhập kho' } },
Expand All @@ -44,7 +47,6 @@ const routes = [
{ path: '/alerts', component: AlertsView, meta: { title: 'Cảnh báo tồn kho' } },
{ path: '/employees', component: EmployeeListView, meta: { title: 'Nhân viên' } },
{ path: '/users', component: UsersView, meta: { title: 'Nhân viên & phân quyền' } },

]

const router = createRouter({
Expand Down
2 changes: 1 addition & 1 deletion src/services/excelImportService.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export async function createImportSession(file, loaiImport, warehouseId) {
formData.append('file', file)
formData.append('loaiImport', loaiImport)
if (warehouseId) {
formData.append('khoId', warehouseId)
formData.append('khoId', warehouseId)
}

const { data } = await excelImportClient.post('/api/excel-imports', formData, {
Expand Down
Loading