This commit is contained in:
jeowsome 2024-10-03 05:23:31 +08:00
commit aa9ece09da
190 changed files with 4850 additions and 0 deletions

21
.editorconfig Normal file
View file

@ -0,0 +1,21 @@
# Root editor config file
root = true
# Common settings
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
# python, js indentation settings
[{*.py,*.js,*.vue,*.css,*.scss,*.html}]
indent_style = tab
indent_size = 4
max_line_length = 99
# JSON files - mostly doctype schema files
[{*.json}]
insert_final_newline = false
indent_style = space
indent_size = 1

124
.eslintrc Normal file
View file

@ -0,0 +1,124 @@
{
"env": {
"browser": true,
"node": true,
"es2022": true
},
"parserOptions": {
"sourceType": "module"
},
"extends": "eslint:recommended",
"rules": {
"indent": "off",
"brace-style": "off",
"no-mixed-spaces-and-tabs": "off",
"no-useless-escape": "off",
"space-unary-ops": ["error", { "words": true }],
"linebreak-style": "off",
"quotes": ["off"],
"semi": "off",
"camelcase": "off",
"no-unused-vars": "off",
"no-console": ["warn"],
"no-extra-boolean-cast": ["off"],
"no-control-regex": ["off"],
},
"root": true,
"globals": {
"frappe": true,
"Vue": true,
"SetVueGlobals": true,
"__": true,
"repl": true,
"Class": true,
"locals": true,
"cint": true,
"cstr": true,
"cur_frm": true,
"cur_dialog": true,
"cur_page": true,
"cur_list": true,
"cur_tree": true,
"msg_dialog": true,
"is_null": true,
"in_list": true,
"has_common": true,
"posthog": true,
"has_words": true,
"validate_email": true,
"open_web_template_values_editor": true,
"validate_name": true,
"validate_phone": true,
"validate_url": true,
"get_number_format": true,
"format_number": true,
"format_currency": true,
"comment_when": true,
"open_url_post": true,
"toTitle": true,
"lstrip": true,
"rstrip": true,
"strip": true,
"strip_html": true,
"replace_all": true,
"flt": true,
"precision": true,
"CREATE": true,
"AMEND": true,
"CANCEL": true,
"copy_dict": true,
"get_number_format_info": true,
"strip_number_groups": true,
"print_table": true,
"Layout": true,
"web_form_settings": true,
"$c": true,
"$a": true,
"$i": true,
"$bg": true,
"$y": true,
"$c_obj": true,
"refresh_many": true,
"refresh_field": true,
"toggle_field": true,
"get_field_obj": true,
"get_query_params": true,
"unhide_field": true,
"hide_field": true,
"set_field_options": true,
"getCookie": true,
"getCookies": true,
"get_url_arg": true,
"md5": true,
"$": true,
"jQuery": true,
"moment": true,
"hljs": true,
"Awesomplete": true,
"Sortable": true,
"Showdown": true,
"Taggle": true,
"Gantt": true,
"Slick": true,
"Webcam": true,
"PhotoSwipe": true,
"PhotoSwipeUI_Default": true,
"io": true,
"JsBarcode": true,
"L": true,
"Chart": true,
"DataTable": true,
"Cypress": true,
"cy": true,
"it": true,
"describe": true,
"expect": true,
"context": true,
"before": true,
"beforeEach": true,
"after": true,
"qz": true,
"localforage": true,
"extend_cscript": true
}
}

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
.DS_Store
*.pyc
*.egg-info
*.swp
tags
node_modules
__pycache__

67
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,67 @@
exclude: 'node_modules|.git'
default_stages: [commit]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
hooks:
- id: trailing-whitespace
files: "suppliers_portal.*"
exclude: ".*json$|.*txt$|.*csv|.*md|.*svg"
- id: check-yaml
- id: check-merge-conflict
- id: check-ast
- id: check-json
- id: check-toml
- id: check-yaml
- id: debug-statements
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.2.0
hooks:
- id: ruff
name: "Run ruff linter and apply fixes"
args: ["--fix"]
- id: ruff-format
name: "Format Python code"
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.7.1
hooks:
- id: prettier
types_or: [javascript, vue, scss]
# Ignore any files that might contain jinja / bundles
exclude: |
(?x)^(
suppliers_portal/public/dist/.*|
.*node_modules.*|
.*boilerplate.*|
suppliers_portal/templates/includes/.*|
suppliers_portal/public/js/lib/.*
)$
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.44.0
hooks:
- id: eslint
types_or: [javascript]
args: ['--quiet']
# Ignore any files that might contain jinja / bundles
exclude: |
(?x)^(
suppliers_portal/public/dist/.*|
cypress/.*|
.*node_modules.*|
.*boilerplate.*|
suppliers_portal/templates/includes/.*|
suppliers_portal/public/js/lib/.*
)$
ci:
autoupdate_schedule: weekly
skip: []
submodules: false

33
README.md Normal file
View file

@ -0,0 +1,33 @@
### Suppliers Portal
Supplier Invoices, Supplier Quotes and Supplier Details
### Installation
You can install this app using the [bench](https://github.com/frappe/bench) CLI:
```bash
cd $PATH_TO_YOUR_BENCH
bench get-app $URL_OF_THIS_REPO --branch develop
bench install-app suppliers_portal
```
### Contributing
This app uses `pre-commit` for code formatting and linting. Please [install pre-commit](https://pre-commit.com/#installation) and enable it for this repository:
```bash
cd apps/suppliers_portal
pre-commit install
```
Pre-commit is configured to use the following tools for checking and formatting your code:
- ruff
- eslint
- prettier
- pyupgrade
### License
mit

5
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
.DS_Store
dist
dist-ssr
*.local

View file

@ -0,0 +1,4 @@
{
"semi": false,
"singleQuote": true
}

42
frontend/README.md Normal file
View file

@ -0,0 +1,42 @@
# Frappe UI Starter
This template should help get you started developing custom frontend for Frappe
apps with Vue 3 and the Frappe UI package.
This boilerplate sets up Vue 3, Vue Router, TailwindCSS, and Frappe UI out of
the box.
## Usage
This template is meant to be cloned inside an existing Frappe App. Assuming your
apps name is `todo`. Clone this template in the root folder of your app using `degit`.
```
cd apps/todo
npx degit netchampfaris/frappe-ui-starter frontend
cd frontend
yarn
yarn dev
```
In a development environment, you need to put the below key-value pair in your `site_config.json` file:
```
"ignore_csrf": 1
```
This will prevent `CSRFToken` errors while using the vite dev server. In production environment, the `csrf_token` is attached to the `window` object in `index.html` for you.
The Vite dev server will start on the port `8080`. This can be changed from `vite.config.js`.
The development server is configured to proxy your frappe app (usually running on port `8000`). If you have a site named `todo.test`, open `http://todo.test:8080` in your browser. If you see a button named "Click to send 'ping' request", congratulations!
If you notice the browser URL is `/frontend`, this is the base URL where your frontend app will run in production.
To change this, open `src/router.js` and change the base URL passed to `createWebHistory`.
## Resources
- [Vue 3](https://v3.vuejs.org/guide/introduction.html)
- [Vue Router](https://next.router.vuejs.org/guide/)
- [Frappe UI](https://github.com/frappe/frappe-ui)
- [TailwindCSS](https://tailwindcss.com/docs/utility-first)
- [Vite](https://vitejs.dev/guide/)

17
frontend/index.html Normal file
View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Supplier Portal</title>
</head>
<body>
<div id="app"></div>
<div id="modals"></div>
<div id="popovers"></div>
<script> window.csrf_token = '{{ csrf_token }}'; </script>
<script type="module" src="/src/main.js"></script>
</body>
</html>

26
frontend/package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "frappe-ui-frontend",
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build --base=/assets/suppliers_portal/frontend/ && yarn copy-html-entry",
"preview": "vite preview",
"copy-html-entry": "cp ../suppliers_portal/public/frontend/index.html ../suppliers_portal/www/frontend.html"
},
"dependencies": {
"bootstrap": "5.2.3",
"bootstrap-icons": "^1.11.3",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.69",
"vue": "^3.2.25",
"vue-router": "^4.0.12"
},
"devDependencies": {
"@vitejs/plugin-vue": "^2.0.0",
"autoprefixer": "^10.4.2",
"postcss": "^8.4.5",
"tailwindcss": "^3.0.15",
"vite": "^2.7.2"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
frontend/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 B

5
frontend/src/App.vue Normal file
View file

@ -0,0 +1,5 @@
<template>
<div>
<router-view />
</div>
</template>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,152 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100;
font-display: swap;
src: url("Inter-Thin.woff2?v=3.12") format("woff2"),
url("Inter-Thin.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 100;
font-display: swap;
src: url("Inter-ThinItalic.woff2?v=3.12") format("woff2"),
url("Inter-ThinItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 200;
font-display: swap;
src: url("Inter-ExtraLight.woff2?v=3.12") format("woff2"),
url("Inter-ExtraLight.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 200;
font-display: swap;
src: url("Inter-ExtraLightItalic.woff2?v=3.12") format("woff2"),
url("Inter-ExtraLightItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url("Inter-Light.woff2?v=3.12") format("woff2"),
url("Inter-Light.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 300;
font-display: swap;
src: url("Inter-LightItalic.woff2?v=3.12") format("woff2"),
url("Inter-LightItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("Inter-Regular.woff2?v=3.12") format("woff2"),
url("Inter-Regular.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("Inter-Italic.woff2?v=3.12") format("woff2"),
url("Inter-Italic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("Inter-Medium.woff2?v=3.12") format("woff2"),
url("Inter-Medium.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 500;
font-display: swap;
src: url("Inter-MediumItalic.woff2?v=3.12") format("woff2"),
url("Inter-MediumItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("Inter-SemiBold.woff2?v=3.12") format("woff2"),
url("Inter-SemiBold.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 600;
font-display: swap;
src: url("Inter-SemiBoldItalic.woff2?v=3.12") format("woff2"),
url("Inter-SemiBoldItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("Inter-Bold.woff2?v=3.12") format("woff2"),
url("Inter-Bold.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url("Inter-BoldItalic.woff2?v=3.12") format("woff2"),
url("Inter-BoldItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url("Inter-ExtraBold.woff2?v=3.12") format("woff2"),
url("Inter-ExtraBold.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 800;
font-display: swap;
src: url("Inter-ExtraBoldItalic.woff2?v=3.12") format("woff2"),
url("Inter-ExtraBoldItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url("Inter-Black.woff2?v=3.12") format("woff2"),
url("Inter-Black.woff?v=3.12") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 900;
font-display: swap;
src: url("Inter-BlackItalic.woff2?v=3.12") format("woff2"),
url("Inter-BlackItalic.woff?v=3.12") format("woff");
}

View file

@ -0,0 +1,91 @@
import router from '@/router'
import { computed, reactive } from 'vue'
import { createResource } from 'frappe-ui'
import { userResource } from './user'
export function sessionUser() {
const cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
let _sessionUser = cookies.get('user_id')
if (_sessionUser === 'Guest') {
_sessionUser = null
}
return _sessionUser
}
export function sessionSupplierId() {
const cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
return cookies.get('supplier_id')
}
export function sessionSupplierName() {
const cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
return cookies.get('supplier_name')
}
export const session = reactive({
supplier_login: createResource({
url: 'suppliers_portal.suppliers_portal.api.validate_supplier_id',
makeParams({ supplier_id }) {
return {
supplier_id,
}
},
onSuccess(data) {
if (data && data.status === 'success') {
userResource.reload()
session.user = sessionUser()
document.cookie = `supplier_id=${data.supplier_id}`
document.cookie = `supplier_name=${data.supplier_name}`
session.supplier_id = data.supplier_id
session.supplier_name = data.supplier_name
session.login.reset()
router.replace({ name: 'SupplierInvoiceList' })
}
},
onError(error) {
console.error('Login failed', error)
},
}),
login: createResource({
url: 'login',
makeParams({ email, password }) {
return {
usr: email,
pwd: password,
}
},
onSuccess(data) {
if (data) {
userResource.reload()
session.user = sessionUser()
// clear supplier_id and supplier_name
document.cookie = `supplier_id=${session.supplier_id}; expires=Thu, 01 Jan 1970 00:00:00 UTC'`;
document.cookie = `supplier_id=${session.supplier_name}; expires=Thu, 01 Jan 1970 00:00:00 UTC'`;
session.supplier_id = ''
session.supplier_name = ''
session.login.reset()
router.replace({ name: 'SupplierInvoiceList' })
}
},
onError(error) {
console.error('Login failed', error)
},
}),
logout: createResource({
url: 'logout',
onSuccess() {
document.cookie = `supplier_id=${session.supplier_id}; expires=Thu, 01 Jan 1970 00:00:00 UTC'`;
document.cookie = `supplier_id=${session.supplier_name}; expires=Thu, 01 Jan 1970 00:00:00 UTC'`;
session.supplier_id = ''
session.supplier_name = ''
userResource.reset()
session.user = sessionUser()
router.replace({ name: 'Login' })
},
}),
user: sessionUser(),
supplier_id: sessionSupplierId(),
supplier_name: sessionSupplierName(),
isLoggedIn: computed(() => !!session.user),
})

12
frontend/src/data/user.js Normal file
View file

@ -0,0 +1,12 @@
import router from '@/router'
import { createResource } from 'frappe-ui'
export const userResource = createResource({
url: 'frappe.auth.get_logged_user',
cache: 'User',
onError(error) {
if (error && error.exc_type === 'AuthenticationError') {
router.push({ name: 'LoginPage' })
}
},
})

View file

@ -0,0 +1,22 @@
export const addDays = (date, days) => {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
export const parseInvoiceTerms = (invoiceTerm) => {
switch (invoiceTerm) {
case 'NET 10':
return 10;
case 'NET 15':
return 15;
case 'NET 30':
return 30;
case 'NET 45':
return 45;
case 'NET 60':
return 60;
default:
return 0;
}
}

2
frontend/src/index.css Normal file
View file

@ -0,0 +1,2 @@
@import './assets/Inter/inter.css';
@import 'frappe-ui/src/style.css';

32
frontend/src/main.js Normal file
View file

@ -0,0 +1,32 @@
import './index.css'
import { createApp } from 'vue'
import router from './router'
import App from './App.vue'
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap'
import 'bootstrap-icons/font/bootstrap-icons.css'
import {
Button,
Card,
Input,
setConfig,
frappeRequest,
resourcesPlugin,
} from 'frappe-ui'
let app = createApp(App)
setConfig('resourceFetcher', frappeRequest)
app.use(router)
app.use(resourcesPlugin)
app.component('Button', Button)
app.component('Card', Card)
app.component('Input', Input)
app.mount('#app')

View file

@ -0,0 +1,66 @@
<template>
<div class="m-3 flex flex-row items-center justify-center">
<Card title="Welcome to Supplier Portal!" class="w-full max-w-md mt-4">
<form class="flex flex-col space-y-2 w-full" @submit.prevent="submit">
<Switch v-model="state.forAdmin" label="Login as Admin" />
<div v-if="state.forAdmin">
<Input
required
name="email"
type="text"
placeholder="username@email.com"
label="User ID"
/>
<Input
required
name="password"
type="password"
placeholder="••••••"
label="Password"
/>
</div>
<div v-else>
<Input
required
name="supplier_id"
type="text"
placeholder="Supplier ID"
label="Supplier ID"
/>
</div>
<Button :loading="session.login.loading" appearance="primary">Login</Button>
<div v-if="session.login.error" class="text-red-500">
{{ session.login.error }}
</div>
</form>
</Card>
</div>
</template>
<script lang="ts" setup>
import Switch from 'frappe-ui/src/components/Switch.vue'
import { reactive } from 'vue'
import { session } from '../data/session'
const state = reactive({
forAdmin: false,
})
if (window.location.href.includes('accounting.seahavenind')) {
window.location.replace('https://accounting.seahavenind.com/erpnext')
console.log('redirected')
}
function submit(e) {
let formData = new FormData(e.target)
if (state.forAdmin) {
session.login.submit({
email: formData.get('email'),
password: formData.get('password'),
})
} else {
session.supplier_login.submit({
supplier_id: formData.get('supplier_id'),
})
}
}
</script>

View file

@ -0,0 +1,597 @@
<template>
<form @submit.prevent="">
<div class="container mt-5 py-3 border">
<div class="row mb-3">
<div class="col-6">
<h1 class="text-gray-900 font-bold text-[32px]">New Invoice</h1>
</div>
<div class="col-6">
<div class="d-flex">
<div class="ms-auto">
<div class="btn-group" role="group" aria-label="Basic example">
<button type="button" class="btn btn-primary btn-light">
<router-link to="/">Cancel</router-link>
</button>
<button
type="button"
class="btn btn-primary btn-dark"
@click="saveInvoice"
>
Save
</button>
</div>
</div>
</div>
</div>
</div>
<div class="container border-bottom pb-3">
<div class="mb-2 row">
<div class="col">
<div class="row">
<label for="supplier_invoice_number" class="col-sm-3 col-form-label"
>Invoice Number</label
>
<div class="col-sm-8">
<input
type="text"
class="form-control"
id="supplier_invoice_number"
v-model.number="invoiceData.supplier_invoice_number"
/>
</div>
</div>
</div>
<div class="col">
<div class="row">
<label for="service_date" class="col-sm-3 col-form-label"
>Service Date</label
>
<div class="col-sm-8">
<TextInput
:type="'date'"
size="lg"
variant="outline"
placeholder="Service Date"
id="service_date"
:disabled="false"
v-model="invoiceData.service_date"
/>
</div>
</div>
</div>
</div>
<div class="mb-2 row">
<div class="col">
<div class="row">
<label for="supplier_id" class="col-sm-3 col-form-label"
>Supplier ID</label
>
<div class="col-sm-8">
<input
type="text"
readonly
class="form-control-plaintext"
id="supplier_id"
v-model="invoiceData.supplier"
/>
</div>
</div>
</div>
<div class="col">
<div class="row">
<label for="invoice_date" class="col-sm-3 col-form-label"
>Invoice Date</label
>
<div class="col-sm-8">
<TextInput
:type="'date'"
size="lg"
variant="outline"
placeholder="Invoice Date"
id="invoice_date"
:disabled="false"
v-model="invoiceData.invoice_date"
/>
</div>
</div>
</div>
</div>
<div class="mb-2 row">
<div class="col">
<div class="row">
<label for="supplier_name" class="col-sm-3 col-form-label"
>Supplier Name</label
>
<div class="col-sm-8">
<input
type="text"
readonly
class="form-control-plaintext"
id="supplier_name"
:value="invoiceData.supplier_name"
v-if="!isAdministrator"
/>
<Autocomplete
id="supplier_name"
:options="portalSuppliers.data"
:placeholder="'Supplier Name'"
:hideSearch="invoiceData.supplier_invoice_number"
:label="'company_name'"
v-model="invoiceData.supplier_name"
v-else
/>
</div>
</div>
</div>
<div class="col">
<div class="row">
<label for="invoice_terms" class="col-sm-3 col-form-label"
>Invoice Terms</label
>
<div class="col-sm-8">
<select
class="form-select form-select-mb h-100 d-inline-block"
aria-label="Invoice Terms"
v-model="invoiceData.invoice_terms"
>
<option readonly></option>
<option
v-for="option in invoiceTermOptions"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</div>
</div>
</div>
<div class="mb-2 row">
<div class="col"></div>
<div class="col">
<div class="row">
<label for="invoice_date" class="col-sm-3 col-form-label"
>Due Date</label
>
<div class="col-sm-8">
<TextInput
:type="'date'"
size="lg"
variant="outline"
placeholder="Due Date"
id="due_date"
:disabled="true"
v-model="invoiceDueDate"
/>
</div>
</div>
</div>
</div>
<div class="mb-2 row">
<div class="col">
<div class="accordion accordion-flush" id="service-address-accordion">
<div class="accordion-item">
<h2 class="accordion-header ps-0">
<button
class="accordion-button"
type="button"
data-bs-toggle="collapse"
data-bs-target="#collapseOne"
aria-expanded="true"
aria-controls="collapseOne"
>
Service Address
</button>
</h2>
<div
id="collapseOne"
class="accordion-collapse collapse show"
data-bs-parent="#service-address-accordion"
>
<div class="accordion-body">
<div class="row mb-1">
<label for="street" class="col-sm-3 col-form-label"
>Street</label
>
<div class="col-sm-8">
<input
type="text"
class="form-control"
id="street"
v-model="invoiceData.street"
/>
</div>
</div>
<div class="row mb-1">
<label for="city" class="col-sm-3 col-form-label"
>City</label
>
<div class="col-sm-8">
<input
type="text"
class="form-control"
id="city"
v-model="invoiceData.city"
/>
</div>
</div>
<div class="row mb-1">
<label for="state" class="col-sm-3 col-form-label"
>State</label
>
<div class="col-sm-8">
<input
type="text"
class="form-control"
id="state"
v-model="invoiceData.state"
/>
</div>
</div>
<div class="row mb-1">
<label for="zip_code" class="col-sm-3 col-form-label"
>Zip Code</label
>
<div class="col-sm-8">
<input
type="text"
class="form-control"
id="zip_code"
v-model="invoiceData.zip_code"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col">
<div class="accordion accordion-flush" id="address-accordion">
<div class="accordion-item">
<h2 class="accordion-header ps-0">
<button
class="accordion-button"
type="button"
data-bs-toggle="collapse"
data-bs-target="#collapse-2"
aria-expanded="true"
aria-controls="collapse-2"
>
{{
sessionSupplierId()
? 'Notes'
: 'Payment Reference and Notes'
}}
</button>
</h2>
<div
id="collapse-2"
class="accordion-collapse collapse show"
data-bs-parent="#address-accordion"
>
<div class="accordion-body">
<div class="row mb-1" v-if="!sessionSupplierId()">
<label
for="payment_reference_no"
class="col-sm-3 col-form-label"
>Payment Reference</label
>
<div class="col-sm-5">
<input
type="text"
class="form-control"
id="payment_reference_no"
v-model="invoiceData.payment_reference_no"
/>
</div>
</div>
<div class="row mb-1" v-if="!sessionSupplierId()">
<label
for="payment_reference_date"
class="col-sm-3 col-form-label"
>Payment Date</label
>
<div class="col-sm-5">
<TextInput
:type="'date'"
size="lg"
variant="outline"
placeholder="Payment Date"
id="payment_reference_date"
:disabled="false"
v-model="invoiceData.payment_reference_date"
/>
</div>
</div>
<div class="row mb-1">
<label for="notes" class="col-sm-3 col-form-label"
>Notes</label
>
<div class="col-md-8">
<Textarea
:variant="'subtle'"
size="md"
placeholder="Enter Notes Here"
:disabled="false"
v-model="invoiceData.notes"
id="notes"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="container">
<h4>Invoice item</h4>
<table class="table" v-if="invoiceItems.length !== 0">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col" width="30%">Service Type</th>
<th scope="col">Quantity</th>
<th scope="col">Rate</th>
<th scope="col">Amount</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr v-for="(item, index) in invoiceItems" :key="index">
<th scope="row">{{ index + 1 }}</th>
<td>
<div class="col-mb-12">
<select
class="form-select form-select-mb h-100 d-inline-block"
aria-label="Service Types"
v-model="item.service_type"
>
<option readonly></option>
<option
v-for="option in serviceTypeOption"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</td>
<td>
<input type="text" class="form-control" v-model="item.quantity" />
</td>
<td>
<div class="input-group">
<span class="input-group-text"
><i class="bi bi-currency-dollar"></i>
</span>
<input
type="text"
class="form-control"
v-model.number="item.rate"
/>
</div>
</td>
<td>
<div class="input-group">
<span class="input-group-text"
><i class="bi bi-currency-dollar"></i>
</span>
<input
type="text"
readonly
class="form-control"
v-model.number="item.amount"
/>
</div>
</td>
<td>
<button type="button" class="btn btn-dark btn-sm">
<i @click="deleteRow(index)" class="bi bi-trash"></i>
</button>
</td>
</tr>
</tbody>
</table>
<div class="d-flex">
<div :class="invoiceItems.length === 0 ? 'ms-auto pt-3' : 'ms-auto'">
<div class="btn-group" role="group" aria-label="Basic example">
<button type="button" class="btn btn-dark btn-sm" @click="addRow">
<i class="bi bi-plus"></i>
</button>
</div>
</div>
</div>
</div>
<div class="container">
<div class="mb-2 row">
<div class="col"></div>
<div class="col">
<div class="row">
<label for="total_amount" class="col-sm-3 col-form-label"
>Total Amount</label
>
<div class="col-sm-5">
<input
type="text"
readonly
class="form-control-plaintext"
id="total_amount"
:value="invoiceData.total_amount"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
</template>
<script setup>
import { computed, reactive, readonly, watch } from 'vue'
import { createListResource } from 'frappe-ui'
import { sessionSupplierId, sessionSupplierName, sessionUser } from '../data/session'
import { addDays, parseInvoiceTerms } from '../data/utils'
import router from '../router'
import TextInput from 'frappe-ui/src/components/TextInput.vue'
import Textarea from 'frappe-ui/src/components/Textarea.vue'
import Autocomplete from 'frappe-ui/src/components/Autocomplete.vue'
const isAdministrator = sessionUser() === 'Administrator'
const portalInvoice = createListResource({
doctype: 'Supplier Portal Invoices',
insert: {
onSuccess() {
router.push({ name: 'SupplierInvoiceList' })
},
},
})
const invoiceTermOptions = [
{
label: 'NET 10',
value: 'NET 10',
},
{
label: 'NET 15',
value: 'NET 15',
},
{
label: 'NET 30',
value: 'NET 30',
},
{
label: 'NET 45',
value: 'NET 45',
},
{
label: 'NET 60',
value: 'NET 60',
},
{
label: 'Due on Receipt',
value: 'Due on Receipt',
},
]
const serviceTypeOption = [
{
label: 'Repair',
value: 'Repair',
},
{
label: 'Preventive Maintenance',
value: 'Preventive Maintenance',
},
]
const invoiceData = reactive({
supplier_invoice_number: '',
supplier: sessionSupplierId(),
service_date: new Date().toISOString().split('T')[0],
invoice_date: new Date().toISOString().split('T')[0],
supplier_name: sessionSupplierName(),
invoice_terms: '',
total_amount: 0,
street: '',
city: '',
state: '',
zip_code: '',
payment_reference_no: '',
payment_reference_date: new Date().toISOString().split('T')[0],
notes: '',
})
const invoiceItems = reactive([])
let invoiceDueDate = computed(() => {
if (invoiceData.invoice_terms) {
let result = addDays(
invoiceData.invoice_date,
parseInvoiceTerms(invoiceData.invoice_terms),
)
// return as string
return result.toISOString().split('T')[0]
}
return ''
})
const addRow = () => {
const rowObject = reactive({
service_type: '',
quantity: 0,
rate: 0,
amount: 0,
})
rowObject.amount = computed(() => rowObject.quantity * rowObject.rate)
invoiceItems.push(rowObject)
}
const deleteRow = (index) => {
invoiceItems.splice(index, 1)
}
invoiceData.total_amount = computed(() => {
return invoiceItems.reduce((acc, item) => acc + item.amount, 0)
})
const saveInvoice = () => {
portalInvoice.insert.submit({
supplier_invoice_number: invoiceData.supplier_invoice_number,
supplier: invoiceData.supplier,
service_date: invoiceData.service_date,
invoice_date: invoiceData.invoice_date,
supplier_name: invoiceData.supplier_name,
invoice_terms: invoiceData.invoice_terms,
street: invoiceData.street,
city: invoiceData.city,
state: invoiceData.state,
zip_code: invoiceData.zip_code,
payment_reference_date: invoiceData.payment_reference_date,
payment_reference_no: invoiceData.payment_reference_no,
notes: invoiceData.notes,
supplier_invoice_items: invoiceItems.map((item) => {
return {
service_type: item.service_type,
quantity: item.quantity,
rate: item.rate,
}
}),
})
}
const portalSuppliers = createListResource({
doctype: 'Portal Supplier',
fields: ['name as value', 'company_name as label'],
orderBy: 'creation desc',
})
portalSuppliers.fetch()
// watch for changes in invoiceData.supplier_name
watch(
() => invoiceData.supplier_name,
(newVal) => {
if (newVal) {
invoiceData.supplier = newVal.value
}
},
)
</script>
<style lang="css" scoped>
input.form-control-plaintext:focus {
background-color: #fff;
border-color: #fff;
box-shadow: none;
}
tr {
vertical-align: middle;
}
</style>

View file

@ -0,0 +1,413 @@
<template>
<div v-if="!invoice.loading && invoice.doc">
<div class="container mt-5 py-3 border">
<div class="row mb-3 vr-aligned">
<div class="col-6">
<h1 class="text-gray-900 font-bold text-[32px]">
Supplier Invoice: {{ invoice.doc.name }}
<Badge>
{{ invoice.doc.status }}
</Badge>
</h1>
</div>
<div class="col-6">
<div class="d-flex">
<div class="ms-auto">
<div class="btn-group" role="group" aria-label="Basic example">
<button type="button" class="btn btn-primary btn-dark">
<router-link to="/">Back</router-link>
</button>
</div>
</div>
</div>
</div>
</div>
<div
:class="
invoice.doc.supplier_invoice_items
? 'container border-bottom pb-3'
: 'container pb-3'
"
>
<div class="mb-1 row">
<div class="col">
<div class="row">
<label for="suppplier_id" class="col-sm-3 col-form-label"
>Supplier ID</label
>
<div class="col-sm-8">
<input
type="text"
readonly
class="form-control-plaintext"
id="suppplier_id"
:value="invoice.doc.supplier"
/>
</div>
</div>
</div>
<div class="col">
<div class="row mb-1">
<label for="service_date" class="col-sm-3 col-form-label"
>Service Date</label
>
<div class="col-sm-8">
<TextInput
:type="'date'"
size="lg"
variant="outline"
placeholder="Payment Date"
id="service_date"
:disabled="true"
v-model="invoice.doc.service_date"
/>
</div>
</div>
</div>
</div>
<div class="mb-1 row">
<div class="col">
<div class="row">
<label for="suppplier_name" class="col-sm-3 col-form-label"
>Supplier Name</label
>
<div class="col-sm-8">
<input
type="text"
readonly
class="form-control-plaintext"
id="suppplier_name"
:value="invoice.doc.supplier_name"
/>
</div>
</div>
</div>
<div class="col">
<div class="row mb-1">
<label for="invoice_date" class="col-sm-3 col-form-label"
>Invoice Date</label
>
<div class="col-sm-8">
<TextInput
:type="'date'"
size="lg"
variant="outline"
placeholder="Invoice Date"
id="invoice_date"
:disabled="true"
v-model="invoice.doc.invoice_date"
/>
</div>
</div>
</div>
</div>
<div class="mb-1 row">
<div class="col"></div>
<div class="col">
<div class="row mb-1">
<label for="due_date" class="col-sm-3 col-form-label">Due Date</label>
<div class="col-sm-8">
<TextInput
:type="'date'"
size="lg"
variant="outline"
placeholder="Due Date"
id="due_date"
:disabled="true"
v-model="invoice.doc.due_date"
/>
</div>
</div>
</div>
</div>
<div class="mb-1 row">
<div class="col"></div>
<div class="col">
<div class="row">
<label for="invoice_terms" class="col-sm-3 col-form-label"
>Invoice Terms</label
>
<div class="col-sm-8">
<input
type="text"
readonly
class="form-control-plaintext h-100 d-inline-block"
id="invoice_terms"
:value="invoice.doc.invoice_terms"
/>
</div>
</div>
</div>
</div>
<div class="mb-1 row">
<div class="col">
<div class="accordion accordion-flush" id="service-address-accordion">
<div class="accordion-item">
<h2 class="accordion-header ps-0">
<button
class="accordion-button"
type="button"
data-bs-toggle="collapse"
data-bs-target="#collapseOne"
aria-expanded="true"
aria-controls="collapseOne"
>
Service Address
</button>
</h2>
<div
id="collapseOne"
class="accordion-collapse collapse"
data-bs-parent="#service-address-accordion"
>
<div class="accordion-body">
<div class="row mb-1">
<label for="street" class="col-sm-3 col-form-label"
>Street</label
>
<div class="col-sm-8">
<input
type="text"
readonly
class="form-control-plaintext"
id="street"
:value="invoice.doc.street"
/>
</div>
</div>
<div class="row mb-1">
<label for="city" class="col-sm-3 col-form-label"
>City</label
>
<div class="col-sm-8">
<input
type="text"
readonly
class="form-control-plaintext"
id="city"
:value="invoice.doc.city"
/>
</div>
</div>
<div class="row mb-1">
<label for="state" class="col-sm-3 col-form-label"
>State</label
>
<div class="col-sm-8">
<input
type="text"
readonly
class="form-control-plaintext"
id="state"
:value="invoice.doc.state"
/>
</div>
</div>
<div class="row mb-1">
<label for="zip_code" class="col-sm-3 col-form-label"
>Zip Code</label
>
<div class="col-sm-8">
<input
type="text"
readonly
class="form-control-plaintext"
id="zip_code"
:value="invoice.doc.zip_code"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col">
<div class="accordion accordion-flush" id="address-accordion">
<div class="accordion-item">
<h2 class="accordion-header ps-0">
<button
class="accordion-button"
type="button"
data-bs-toggle="collapse"
data-bs-target="#collapse-2"
aria-expanded="true"
aria-controls="collapse-2"
>
Payment Reference and Notes
</button>
</h2>
<div
id="collapse-2"
class="accordion-collapse collapse"
data-bs-parent="#address-accordion"
>
<div class="accordion-body">
<div
class="row mb-1"
v-if="invoice.doc.payment_reference_no"
>
<label
for="payment_reference_no"
class="col-sm-4 col-form-label"
>Payment Reference</label
>
<div class="col-sm-8">
<input
type="text"
readonly
class="form-control-plaintext"
id="payment_reference_no"
:value="invoice.doc.payment_reference_no"
/>
</div>
</div>
<div
class="row mb-1"
v-if="invoice.doc.payment_reference_date"
>
<label
for="payment_reference_date"
class="col-sm-4 col-form-label"
>Payment Date</label
>
<div class="col-sm-8">
<TextInput
:type="'date'"
size="lg"
variant="outline"
placeholder="Payment Date"
id="payment_reference_date"
:disabled="true"
v-model="invoice.doc.payment_reference_date"
/>
</div>
</div>
<div class="row mb-1">
<label for="notes" class="col-sm-4 col-form-label"
>Notes</label
>
<div class="col-sm-8">
<Textarea
:variant="'subtle'"
size="md"
placeholder="Enter Notes Here"
:disabled="true"
v-model="invoice.doc.notes"
label=""
id="notes"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="container" v-if="invoice.doc.supplier_invoice_items">
<table class="table">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col" width="30%">Service Type</th>
<th scope="col">Quantity</th>
<th scope="col">Rate</th>
<th scope="col">Amount</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr
v-for="(item, index) in invoice.doc.supplier_invoice_items"
:key="item.name"
>
<th scope="row">{{ index + 1 }}</th>
<td>{{ item.service_type }}</td>
<td>{{ item.quantity }}</td>
<td><i class="bi bi-currency-dollar"></i> {{ item.rate }}</td>
<td><i class="bi bi-currency-dollar"></i> {{ item.amount }}</td>
</tr>
</tbody>
</table>
</div>
<div class="container">
<div class="mb-1 row">
<div class="col"></div>
<div class="col">
<div class="input-group">
<label for="total_amount" class="col-sm-3 col-form-label"
>Total Amount</label
>
<div class="cols-sm-5">
<div class="input-group">
<span class="input-group-text"
><i class="bi bi-currency-dollar"></i>
</span>
<input
type="text"
readonly
class="form-control"
v-model.number="invoice.doc.total_amount"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { createDocumentResource, createListResource, Badge } from 'frappe-ui'
import Textarea from 'frappe-ui/src/components/Textarea.vue'
import TextInput from 'frappe-ui/src/components/TextInput.vue'
const props = defineProps({
supplierInvoiceNumber: {
type: String,
required: true,
},
})
const invoice = createDocumentResource({
doctype: 'Supplier Portal Invoices',
name: props.supplierInvoiceNumber,
auto: true,
})
</script>
<style lang="css" scoped>
.vr-aligned {
vertical-align: middle;
}
button.accordion-button[aria-expanded='true'] {
color: black;
}
input.form-control-plaintext:focus,
.input-group > input.form-control:focus,
input.form-control,
.input-group > span {
background-color: #fff;
border-color: #fff;
box-shadow: none;
}
.disabled-date {
color: black;
background: transparent;
border: none !important;
border-bottom: 1px solid #ced4da !important;
}
a {
text-decoration: none;
color: inherit;
}
</style>

View file

@ -0,0 +1,94 @@
<template>
<div class="container-fluid">
<div class="container mx-auto mt-5">
<div class="row">
<div class="col">
<h1 class="text-gray-900 font-bold text-[32px]">Supplier Invoices</h1>
<div class="d-flex">
<div>
<div class="btn-group" role="group" aria-label="Basic example">
<button type="button" class="btn btn-sm btn-primary btn-dark"
@click="session.logout.submit">Logout
</button>
</div>
</div>
</div>
</div>
<div class="col">
<div class="d-flex">
<div class="ms-auto">
<div class="btn-group" role="group" aria-label="Basic example">
<router-link to="/invoices/new">
<button type="button" class="btn btn-primary btn-dark">New</button>
</router-link>
</div>
</div>
</div>
</div>
</div>
<div class="container mx-auto mb-3 mt-5" v-if="invoice.data">
<table class=" table">
<thead>
<tr>
<th scope="col">Invoice Number</th>
<th scope="col">Supplier</th>
<th scope="col">Status</th>
<th scope="col">Amount</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr v-for="(item, index) in invoice.data" :key="index">
<td> <router-link
:to="{ name: 'SupplierInvoice', params: { supplierInvoiceNumber: item.name } }">{{
item.name }}</router-link>
</td>
<td>{{ item.supplier }}</td>
<td>
<Badge>{{ item.status }}</Badge>
</td>
<td><i class="bi bi-currency-dollar"></i> {{ item.total_amount }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="flex justify-between mt-4" v-if="invoice.length > 10">
<Button @click="invoice.previous()"> Previous Page </Button>
<Button @click="invoice.next()"> Next Page </Button>
</div>
</div>
</template>
<script setup>
import { createListResource, Badge } from 'frappe-ui'
import { session } from '../data/session';
let invoiceParams = {
doctype: 'Supplier Portal Invoices',
fields: ['*'],
orderBy: 'creation desc',
start: 0,
pageLength: 5,
auto: true,
}
let invoice = null
if (session.supplier_id && session.isLoggedIn) {
invoiceParams.filters = [['supplier', '=', session.supplier_id]]
invoice = createListResource(invoiceParams)
} else {
invoice = createListResource(invoiceParams)
}
invoice.fetch()
</script>
<style lang="css" scoped>
a {
text-decoration: none;
color: inherit;
}
</style>

View file

51
frontend/src/router.js Normal file
View file

@ -0,0 +1,51 @@
import { createRouter, createWebHistory } from 'vue-router'
import { session } from './data/session'
import { userResource } from '@/data/user'
const routes = [
{
name: 'Login',
path: '/account/login',
component: () => import('@/pages/Login.vue'),
},
{
name: 'SupplierInvoiceList',
path: '/',
component: () => import('@/pages/SupplierInvoiceList.vue'),
},
{
name: 'SupplierInvoice',
path: '/invoices/:supplierInvoiceNumber',
component: () => import('@/pages/SupplierInvoice.vue'),
props: true,
},
{
name: 'SupplierInvoiceCreate',
path: '/invoices/new',
component: () => import('@/pages/NewInvoice.vue'),
},
]
let router = createRouter({
history: createWebHistory('/supplier-portal'),
routes,
})
router.beforeEach(async (to, from, next) => {
let isLoggedIn = session.isLoggedIn
try {
await userResource.promise
} catch (error) {
isLoggedIn = false
}
if (to.name === 'Login' && isLoggedIn) {
next({ name: 'SupplierInvoiceList' })
} else if (to.name !== 'Login' && !isLoggedIn) {
next({ name: 'Login' })
} else {
next()
}
})
export default router

View file

@ -0,0 +1,18 @@
module.exports = {
presets: [
require('frappe-ui/src/utils/tailwind.config')
],
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
"./node_modules/frappe-ui/src/components/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
container: {
center: true,
}
},
},
plugins: [],
}

35
frontend/vite.config.js Normal file
View file

@ -0,0 +1,35 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import { webserver_port } from '../../../sites/common_site_config.json'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
port: 8000,
proxy: {
'^/(app|login|api|assets|files)': {
target: `http://127.0.0.1:${webserver_port}`,
ws: true,
router: function (req) {
const site_name = req.headers.host.split(':')[0]
return `http://${site_name}:${webserver_port}`
},
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
build: {
outDir: `../${path.basename(path.resolve('..'))}/public/frontend`,
emptyOutDir: true,
target: 'es2015',
},
optimizeDeps: {
include: ['frappe-ui > feather-icons', 'showdown', 'engine.io-client'],
},
})

1801
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load diff

21
license.txt Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

55
pyproject.toml Normal file
View file

@ -0,0 +1,55 @@
[project]
name = "suppliers_portal"
authors = [
{ name = "jeowsome15@gmail.com", email = "jeowsome15@gmail.com"}
]
description = "Supplier Invoices, Supplier Quotes and Supplier Details"
requires-python = ">=3.10"
readme = "README.md"
dynamic = ["version"]
dependencies = [
# "frappe~=15.0.0" # Installed and managed by bench.
]
[build-system]
requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"
# These dependencies are only installed when developer mode is enabled
[tool.bench.dev-dependencies]
# package_name = "~=1.1.0"
[tool.ruff]
line-length = 110
target-version = "py310"
[tool.ruff.lint]
select = [
"F",
"E",
"W",
"I",
"UP",
"B",
]
ignore = [
"B017", # assertRaises(Exception) - should be more specific
"B018", # useless expression, not assigned to anything
"B023", # function doesn't bind loop variable - will have last iteration's value
"B904", # raise inside except without from
"E101", # indentation contains mixed spaces and tabs
"E402", # module level import not at top of file
"E501", # line too long
"E741", # ambiguous variable name
"F401", # "unused" imports
"F403", # can't detect undefined names from * import
"F405", # can't detect undefined names from * import
"F722", # syntax error in forward type annotation
"W191", # indentation contains tabs
]
typing-modules = ["frappe.types.DF"]
[tool.ruff.format]
quote-style = "double"
indent-style = "tab"
docstring-code-format = true

View file

@ -0,0 +1 @@
__version__ = "0.0.1"

View file

249
suppliers_portal/hooks.py Normal file
View file

@ -0,0 +1,249 @@
app_name = "suppliers_portal"
app_title = "Suppliers Portal"
app_publisher = "jeowsome15@gmail.com"
app_description = "Supplier Invoices, Supplier Quotes and Supplier Details"
app_email = "jeowsome15@gmail.com"
app_license = "mit"
# Apps
# ------------------
# required_apps = []
# Each item in the list will be shown as an app in the apps page
# add_to_apps_screen = [
# {
# "name": "suppliers_portal",
# "logo": "/assets/suppliers_portal/logo.png",
# "title": "Suppliers Portal",
# "route": "/suppliers_portal",
# "has_permission": "suppliers_portal.api.permission.has_app_permission"
# }
# ]
# Includes in <head>
# ------------------
# include js, css files in header of desk.html
# app_include_css = "/assets/suppliers_portal/css/suppliers_portal.css"
# app_include_js = "/assets/suppliers_portal/js/suppliers_portal.js"
# include js, css files in header of web template
# web_include_css = "/assets/suppliers_portal/css/suppliers_portal.css"
# web_include_js = "/assets/suppliers_portal/js/suppliers_portal.js"
# include custom scss in every website theme (without file extension ".scss")
# website_theme_scss = "suppliers_portal/public/scss/website"
# include js, css files in header of web form
# webform_include_js = {"doctype": "public/js/doctype.js"}
# webform_include_css = {"doctype": "public/css/doctype.css"}
# include js in page
# page_js = {"page" : "public/js/file.js"}
# include js in doctype views
# doctype_js = {"doctype" : "public/js/doctype.js"}
# doctype_list_js = {"doctype" : "public/js/doctype_list.js"}
# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"}
# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"}
# Svg Icons
# ------------------
# include app icons in desk
# app_include_icons = "suppliers_portal/public/icons.svg"
# Home Pages
# ----------
# application home page (will override Website Settings)
# home_page = "login"
# website user home page (by Role)
# role_home_page = {
# "Role": "home_page"
# }
# Generators
# ----------
# automatically create page for each record of this doctype
# website_generators = ["Web Page"]
# Jinja
# ----------
# add methods and filters to jinja environment
# jinja = {
# "methods": "suppliers_portal.utils.jinja_methods",
# "filters": "suppliers_portal.utils.jinja_filters"
# }
# Installation
# ------------
# before_install = "suppliers_portal.install.before_install"
# after_install = "suppliers_portal.install.after_install"
# Uninstallation
# ------------
# before_uninstall = "suppliers_portal.uninstall.before_uninstall"
# after_uninstall = "suppliers_portal.uninstall.after_uninstall"
# Integration Setup
# ------------------
# To set up dependencies/integrations with other apps
# Name of the app being installed is passed as an argument
# before_app_install = "suppliers_portal.utils.before_app_install"
# after_app_install = "suppliers_portal.utils.after_app_install"
# Integration Cleanup
# -------------------
# To clean up dependencies/integrations with other apps
# Name of the app being uninstalled is passed as an argument
# before_app_uninstall = "suppliers_portal.utils.before_app_uninstall"
# after_app_uninstall = "suppliers_portal.utils.after_app_uninstall"
# Desk Notifications
# ------------------
# See frappe.core.notifications.get_notification_config
# notification_config = "suppliers_portal.notifications.get_notification_config"
# Permissions
# -----------
# Permissions evaluated in scripted ways
# permission_query_conditions = {
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
# }
#
# has_permission = {
# "Event": "frappe.desk.doctype.event.event.has_permission",
# }
# DocType Class
# ---------------
# Override standard doctype classes
# override_doctype_class = {
# "ToDo": "custom_app.overrides.CustomToDo"
# }
# Document Events
# ---------------
# Hook on document methods and events
# doc_events = {
# "*": {
# "on_update": "method",
# "on_cancel": "method",
# "on_trash": "method"
# }
# }
# Scheduled Tasks
# ---------------
# scheduler_events = {
# "all": [
# "suppliers_portal.tasks.all"
# ],
# "daily": [
# "suppliers_portal.tasks.daily"
# ],
# "hourly": [
# "suppliers_portal.tasks.hourly"
# ],
# "weekly": [
# "suppliers_portal.tasks.weekly"
# ],
# "monthly": [
# "suppliers_portal.tasks.monthly"
# ],
# }
# Testing
# -------
# before_tests = "suppliers_portal.install.before_tests"
# Overriding Methods
# ------------------------------
#
# override_whitelisted_methods = {
# "frappe.desk.doctype.event.event.get_events": "suppliers_portal.event.get_events"
# }
#
# each overriding function accepts a `data` argument;
# generated from the base implementation of the doctype dashboard,
# along with any modifications made in other Frappe apps
# override_doctype_dashboards = {
# "Task": "suppliers_portal.task.get_dashboard_data"
# }
# exempt linked doctypes from being automatically cancelled
#
# auto_cancel_exempted_doctypes = ["Auto Repeat"]
# Ignore links to specified DocTypes when deleting documents
# -----------------------------------------------------------
# ignore_links_on_delete = ["Communication", "ToDo"]
# Request Events
# ----------------
# before_request = ["suppliers_portal.utils.before_request"]
# after_request = ["suppliers_portal.utils.after_request"]
# Job Events
# ----------
# before_job = ["suppliers_portal.utils.before_job"]
# after_job = ["suppliers_portal.utils.after_job"]
# User Data Protection
# --------------------
# user_data_fields = [
# {
# "doctype": "{doctype_1}",
# "filter_by": "{filter_by}",
# "redact_fields": ["{field_1}", "{field_2}"],
# "partial": 1,
# },
# {
# "doctype": "{doctype_2}",
# "filter_by": "{filter_by}",
# "partial": 1,
# },
# {
# "doctype": "{doctype_3}",
# "strict": False,
# },
# {
# "doctype": "{doctype_4}"
# }
# ]
# Authentication and authorization
# --------------------------------
# auth_hooks = [
# "suppliers_portal.auth.validate"
# ]
# Automatically update python controller files with type annotations for this app.
# export_python_type_annotations = True
# default_log_clearing_doctypes = {
# "Logging DocType Name": 30 # days to retain logs
# }
website_route_rules = [
{"from_route": "/erpnext", "to_route": "/login"},
{"from_route": "/frontend/<path:app_path>", "to_route": "frontend"},
]

View file

@ -0,0 +1 @@
Suppliers Portal

View file

@ -0,0 +1,6 @@
[pre_model_sync]
# Patches added in this section will be executed before doctypes are migrated
# Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations
[post_model_sync]
# Patches added in this section will be executed after doctypes are migrated

View file

View file

@ -0,0 +1 @@
import{_ as x,P as g,N as _,O as l,w as r,g as c,o,Q as p,R as C,S as k,m as a,e as d,y as u,T as m,F as h,E as b}from"./vendor.ea68c68e.js";const v={name:"FontColor",props:["editor"],components:{Popover:g,Tooltip:_},methods:{setBackgroundColor(t){t.name!="Default"?this.editor.chain().focus().toggleHighlight({color:t.hex}).run():this.editor.chain().focus().unsetHighlight().run()},setForegroundColor(t){t.name!="Default"?this.editor.chain().focus().setColor(t.hex).run():this.editor.chain().focus().unsetColor().run()}},computed:{foregroundColors(){return[{name:"Default",hex:"#1F272E"},{name:"Yellow",hex:"#ca8a04"},{name:"Orange",hex:"#ea580c"},{name:"Red",hex:"#dc2626"},{name:"Green",hex:"#16a34a"},{name:"Blue",hex:"#1579D0"},{name:"Purple",hex:"#9333ea"},{name:"Pink",hex:"#db2777"}]},backgroundColors(){return[{name:"Default",hex:null},{name:"Yellow",hex:"#fef9c3"},{name:"Orange",hex:"#ffedd5"},{name:"Red",hex:"#fee2e2"},{name:"Green",hex:"#dcfce7"},{name:"Blue",hex:"#D3E9FC"},{name:"Purple",hex:"#f3e8ff"},{name:"Pink",hex:"#fce7f3"}]}}},y={class:"p-2"},P=a("div",{class:"text-sm text-gray-700"},"Text Color",-1),B={class:"mt-1 grid grid-cols-8 gap-1"},F=["aria-label","onClick"],w=a("div",{class:"mt-2 text-sm text-gray-700"},"Background Color",-1),D={class:"mt-1 grid grid-cols-8 gap-1"},T=["aria-label","onClick"];function E(t,R,$,j,z,n){const i=c("Tooltip"),f=c("Popover");return o(),l(f,{transition:"default"},{target:r(({togglePopover:e,isOpen:s})=>[p(t.$slots,"default",C(k({onClick:()=>e(),isActive:s})))]),"body-main":r(()=>[a("div",y,[P,a("div",B,[(o(!0),d(h,null,u(n.foregroundColors,e=>(o(),l(i,{class:"flex",key:e.name,text:e.name},{default:r(()=>[a("button",{"aria-label":e.name,class:"flex h-5 w-5 items-center justify-center rounded border text-base",style:m({color:e.hex}),onClick:s=>n.setForegroundColor(e)}," A ",12,F)]),_:2},1032,["text"]))),128))]),w,a("div",D,[(o(!0),d(h,null,u(n.backgroundColors,e=>(o(),l(i,{class:"flex",key:e.name,text:e.name},{default:r(()=>[a("button",{"aria-label":e.name,class:b(["flex h-5 w-5 items-center justify-center rounded border text-base text-gray-900",e.hex?"border-transparent":"border-gray-200"]),style:m({backgroundColor:e.hex}),onClick:s=>n.setBackgroundColor(e)}," A ",14,T)]),_:2},1032,["text"]))),128))])])]),_:3})}var O=x(v,[["render",E]]);export{O as default};

View file

@ -0,0 +1 @@
import{_ as f,j as I,V as _,X as h,e as g,Q as D,R as v,S as y,f as n,w as l,F as w,g as m,o as c,m as i,t as C,p as S,v as u}from"./vendor.ea68c68e.js";const V={name:"InsertImage",props:["editor"],expose:["openDialog"],data(){return{addImageDialog:{url:"",file:null,show:!1}}},components:{Button:I,Dialog:_},methods:{openDialog(){this.addImageDialog.show=!0},onImageSelect(t){let e=t.target.files[0];!e||(this.addImageDialog.file=e,h(e).then(r=>{this.addImageDialog.url=r}))},addImage(t){this.editor.chain().focus().setImage({src:t}).run(),this.reset()},reset(){this.addImageDialog=this.$options.data().addImageDialog}}},b={class:"relative cursor-pointer rounded-lg bg-gray-100 py-1 focus-within:bg-gray-200 hover:bg-gray-200"},k={class:"absolute inset-0 select-none px-2 py-1 text-base"},x=["src"],B=u(" Insert Image "),N=u(" Cancel ");function A(t,e,r,j,a,o){const d=m("Button"),p=m("Dialog");return c(),g(w,null,[D(t.$slots,"default",v(y({onClick:o.openDialog}))),n(p,{options:{title:"Add Image"},modelValue:a.addImageDialog.show,"onUpdate:modelValue":e[2]||(e[2]=s=>a.addImageDialog.show=s),onAfterLeave:o.reset},{"body-content":l(()=>[i("label",b,[i("input",{type:"file",class:"w-full opacity-0",onChange:e[0]||(e[0]=(...s)=>o.onImageSelect&&o.onImageSelect(...s)),accept:"image/*"},null,32),i("span",k,C(a.addImageDialog.file?"Select another image":"Select an image"),1)]),a.addImageDialog.url?(c(),g("img",{key:0,src:a.addImageDialog.url,class:"mt-2 w-full rounded-lg"},null,8,x)):S("",!0)]),actions:l(()=>[n(d,{variant:"solid",onClick:e[1]||(e[1]=s=>o.addImage(a.addImageDialog.url))},{default:l(()=>[B]),_:1}),n(d,{onClick:o.reset},{default:l(()=>[N]),_:1},8,["onClick"])]),_:1},8,["modelValue","onAfterLeave"])],64)}var L=f(V,[["render",A]]);export{L as default};

View file

@ -0,0 +1 @@
import{_ as d,j as g,U as m,V as L,e as f,Q as p,R as D,S as c,f as i,w as l,F as h,g as a,o as _,W as v,v as w}from"./vendor.ea68c68e.js";const V={name:"InsertLink",props:["editor"],components:{Button:g,FormControl:m,Dialog:L},data(){return{setLinkDialog:{url:"",show:!1}}},methods:{openDialog(){let t=this.editor.getAttributes("link").href;t&&(this.setLinkDialog.url=t),this.setLinkDialog.show=!0},setLink(t){t===""?this.editor.chain().focus().extendMarkRange("link").unsetLink().run():this.editor.chain().focus().extendMarkRange("link").setLink({href:t}).run(),this.setLinkDialog.show=!1,this.setLinkDialog.url=""},reset(){this.setLinkDialog=this.$options.data().setLinkDialog}}},x=w(" Save ");function C(t,e,R,B,n,s){const r=a("FormControl"),u=a("Button"),k=a("Dialog");return _(),f(h,null,[p(t.$slots,"default",D(c({onClick:s.openDialog}))),i(k,{options:{title:"Set Link"},modelValue:n.setLinkDialog.show,"onUpdate:modelValue":e[3]||(e[3]=o=>n.setLinkDialog.show=o),onAfterLeave:s.reset},{"body-content":l(()=>[i(r,{type:"text",label:"URL",modelValue:n.setLinkDialog.url,"onUpdate:modelValue":e[0]||(e[0]=o=>n.setLinkDialog.url=o),onKeydown:e[1]||(e[1]=v(o=>s.setLink(o.target.value),["enter"]))},null,8,["modelValue"])]),actions:l(()=>[i(u,{variant:"solid",onClick:e[2]||(e[2]=o=>s.setLink(n.setLinkDialog.url))},{default:l(()=>[x]),_:1})]),_:1},8,["modelValue","onAfterLeave"])],64)}var U=d(V,[["render",C]]);export{U as default};

View file

@ -0,0 +1 @@
import{_ as D,j as v,V as h,Y as C,e as u,Q as k,R as w,S as x,f as t,w as i,F as B,g as r,o as c,m as y,v as n,t as U,O as F,p}from"./vendor.ea68c68e.js";const S={name:"InsertImage",props:["editor"],expose:["openDialog"],data(){return{addVideoDialog:{url:"",file:null,show:!1}}},components:{Button:v,Dialog:h,FileUploader:C},methods:{openDialog(){this.addVideoDialog.show=!0},onVideoSelect(l){let o=l.target.files[0];!o||(this.addVideoDialog.file=o)},addVideo(l){this.editor.chain().focus().insertContent(`<video src="${l}"></video>`).run(),this.reset()},reset(){this.addVideoDialog=this.$options.data().addVideoDialog}}},I={class:"flex items-center space-x-2"},N=n(" Remove "),A=["src"],R=n(" Insert Video "),j=n("Cancel");function L(l,o,P,b,e,s){const a=r("Button"),V=r("FileUploader"),g=r("Dialog");return c(),u(B,null,[k(l.$slots,"default",w(x({onClick:s.openDialog}))),t(g,{options:{title:"Add Video"},modelValue:e.addVideoDialog.show,"onUpdate:modelValue":o[2]||(o[2]=d=>e.addVideoDialog.show=d),onAfterLeave:s.reset},{"body-content":i(()=>[t(V,{"file-types":"video/*",onSuccess:o[0]||(o[0]=d=>e.addVideoDialog.url=d.file_url)},{default:i(({file:d,progress:f,uploading:_,openFileSelector:m})=>[y("div",I,[t(a,{onClick:m},{default:i(()=>[n(U(_?`Uploading ${f}%`:e.addVideoDialog.url?"Change Video":"Upload Video"),1)]),_:2},1032,["onClick"]),e.addVideoDialog.url?(c(),F(a,{key:0,onClick:()=>{e.addVideoDialog.url=null,e.addVideoDialog.file=null}},{default:i(()=>[N]),_:2},1032,["onClick"])):p("",!0)])]),_:1}),e.addVideoDialog.url?(c(),u("video",{key:0,src:e.addVideoDialog.url,class:"mt-2 w-full rounded-lg",type:"video/mp4",controls:""},null,8,A)):p("",!0)]),actions:i(()=>[t(a,{variant:"solid",onClick:o[1]||(o[1]=d=>s.addVideo(e.addVideoDialog.url))},{default:i(()=>[R]),_:1}),t(a,{onClick:s.reset},{default:i(()=>[j]),_:1},8,["onClick"])]),_:1},8,["modelValue","onAfterLeave"])],64)}var E=D(S,[["render",L]]);export{E as default};

Some files were not shown because too many files have changed in this diff Show more