I need reactjs front-end, springboot backend and m...
创建于:2025年9月3日
使用 GPT-5 Thinking 回答 Chat01
创建于:2025年9月3日
使用 GPT-5 Thinking 回答 Chat01
I need reactjs front-end, springboot backend and mysql backend account tree, tree view code
Awesome — here’s a clean, copy-pasteable “account tree” stack: React (tree view UI) + Spring Boot (REST API) + MySQL (adjacency list). It’s small but production-friendly (DTOs, sorting, CORS), and you can CRUD nodes.
sql-- schema.sql (run once, or let Hibernate create it and just run the inserts) CREATE TABLE IF NOT EXISTS accounts ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, code VARCHAR(64) NULL, parent_id BIGINT NULL, sort_order INT DEFAULT 0, CONSTRAINT fk_accounts_parent FOREIGN KEY (parent_id) REFERENCES accounts(id) ON DELETE CASCADE ); CREATE INDEX idx_accounts_parent ON accounts(parent_id); CREATE INDEX idx_accounts_sort ON accounts(sort_order, name);
sql-- data.sql (optional sample chart of accounts) INSERT INTO accounts (name, code, parent_id, sort_order) VALUES ('Assets', '1000', NULL, 1), ('Cash', '1010', 1, 1), ('Bank', '1011', 2, 1), ('Petty Cash', '1012', 2, 2), ('Accounts Receivable', '1100', 1, 2), ('Inventory', '1200', 1, 3), ('Liabilities', '2000', NULL, 2), ('Accounts Payable', '2100', 7, 1), ('Accrued Expenses', '2200', 7, 2), ('Equity', '3000', NULL, 3), ('Retained Earnings', '3100', 10, 1);
pom.xmlxml<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>account-tree</artifactId> <version>0.0.1</version> <properties> <java.version>17</java.version> <spring.boot.version>3.3.2</spring.boot.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring.boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.6.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
src/main/resources/application.propertiespropertiesspring.datasource.url=jdbc:mysql://localhost:3306/accounting?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC spring.datasource.username=your_user spring.datasource.password=your_password spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=false spring.jpa.properties.hibernate.format_sql=true # CORS quick allow for local dev (React at 5173/3000) app.cors.allowed-origins=http://localhost:5173,http://localhost:3000
Account.java
javapackage com.example.accounttree.model; import jakarta.persistence.*; import lombok.*; @Entity @Table(name = "accounts") @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder public class Account { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String name; @Column(length = 64) private String code; private Integer sortOrder = 0; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_id") private Account parent; }
AccountNode.java (DTO for tree)
javapackage com.example.accounttree.dto; import java.util.ArrayList; import java.util.List; public record AccountNode( Long id, String name, String code, Integer sortOrder, List<AccountNode> children ){ public static AccountNode of(Long id, String name, String code, Integer sortOrder){ return new AccountNode(id, name, code, sortOrder, new ArrayList<>()); } }
AccountCreateUpdateDto.java
javapackage com.example.accounttree.dto; import jakarta.validation.constraints.NotBlank; public record AccountCreateUpdateDto( @NotBlank String name, String code, Long parentId, Integer sortOrder ) {}
AccountRepository.java
javapackage com.example.accounttree.repo; import com.example.accounttree.model.Account; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface AccountRepository extends JpaRepository<Account, Long> { List<Account> findAllByOrderBySortOrderAscNameAsc(); List<Account> findByParent_IdOrderBySortOrderAscNameAsc(Long parentId); }
AccountService.java
javapackage com.example.accounttree.service; import com.example.accounttree.dto.AccountCreateUpdateDto; import com.example.accounttree.dto.AccountNode; import com.example.accounttree.model.Account; import com.example.accounttree.repo.AccountRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.*; @Service public class AccountService { private final AccountRepository repo; public AccountService(AccountRepository repo) { this.repo = repo; } @Transactional(readOnly = true) public List<AccountNode> getTree() { List<Account> all = repo.findAllByOrderBySortOrderAscNameAsc(); Map<Long, AccountNode> nodeById = new HashMap<>(); Map<Long, Long> parentById = new HashMap<>(); List<AccountNode> roots = new ArrayList<>(); for (Account a : all) { AccountNode node = AccountNode.of(a.getId(), a.getName(), a.getCode(), a.getSortOrder()); nodeById.put(a.getId(), node); if (a.getParent() != null) parentById.put(a.getId(), a.getParent().getId()); } for (Account a : all) { AccountNode node = nodeById.get(a.getId()); Long parentId = (a.getParent() != null ? a.getParent().getId() : null); if (parentId == null) { roots.add(node); } else { AccountNode parent = nodeById.get(parentId); if (parent != null) parent.children().add(node); } } // stable order by (sortOrder, name) is already applied; children kept in insertion order. return roots; } @Transactional public Account create(AccountCreateUpdateDto dto) { Account entity = new Account(); applyDto(entity, dto); return repo.save(entity); } @Transactional public Account update(Long id, AccountCreateUpdateDto dto) { Account entity = repo.findById(id).orElseThrow(); applyDto(entity, dto); return repo.save(entity); } @Transactional public void delete(Long id) { repo.deleteById(id); // cascade deletes children via FK } private void applyDto(Account entity, AccountCreateUpdateDto dto) { entity.setName(dto.name()); entity.setCode(dto.code()); entity.setSortOrder(dto.sortOrder() == null ? 0 : dto.sortOrder()); if (dto.parentId() == null) { entity.setParent(null); } else { Account parent = repo.findById(dto.parentId()) .orElseThrow(() -> new NoSuchElementException("Parent not found: " + dto.parentId())); if (entity.getId() != null && entity.getId().equals(dto.parentId())) { throw new IllegalArgumentException("Node cannot be its own parent"); } entity.setParent(parent); } } }
AccountController.java
javapackage com.example.accounttree.web; import com.example.accounttree.dto.AccountCreateUpdateDto; import com.example.accounttree.dto.AccountNode; import com.example.accounttree.model.Account; import com.example.accounttree.service.AccountService; import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/api/accounts") @CrossOrigin(origins = {"http://localhost:5173","http://localhost:3000"}) // dev public class AccountController { private final AccountService service; public AccountController(AccountService service) { this.service = service; } @GetMapping("/tree") public List<AccountNode> tree() { return service.getTree(); } @PostMapping public Account create(@RequestBody @Valid AccountCreateUpdateDto dto) { return service.create(dto); } @PutMapping("/{id}") public Account update(@PathVariable Long id, @RequestBody @Valid AccountCreateUpdateDto dto) { return service.update(id, dto); } @DeleteMapping("/{id}") public void delete(@PathVariable Long id) { service.delete(id); } }
AccountTreeApplication.java
javapackage com.example.accounttree; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class AccountTreeApplication { public static void main(String[] args) { SpringApplication.run(AccountTreeApplication.class, args); } }
Run it:
bashmvn spring-boot:run # API available at http://localhost:8080 (Swagger UI at /swagger-ui/index.html)
bashnpm create vite@latest account-tree-ui -- --template react cd account-tree-ui npm install
Replace/add the following files.
src/types.tstsexport type AccountNode = { id: number; name: string; code?: string | null; sortOrder?: number | null; children: AccountNode[]; };
src/api.tstsconst API = 'http://localhost:8080/api/accounts'; export async function fetchTree() { const res = await fetch(`${API}/tree`); if (!res.ok) throw new Error('Failed to load tree'); return res.json(); } export async function createNode(payload: { name: string; code?: string; parentId?: number | null; sortOrder?: number; }) { const res = await fetch(`${API}`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(payload), }); if (!res.ok) throw new Error('Create failed'); return res.json(); } export async function updateNode(id: number, payload: { name: string; code?: string; parentId?: number | null; sortOrder?: number; }) { const res = await fetch(`${API}/${id}`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(payload), }); if (!res.ok) throw new Error('Update failed'); return res.json(); } export async function deleteNode(id: number) { const res = await fetch(`${API}/${id}`, { method: 'DELETE' }); if (!res.ok) throw new Error('Delete failed'); }
src/components/TreeNode.tsxtsximport React, { useMemo, useState } from 'react'; import type { AccountNode } from '../types'; type Props = { node: AccountNode; depth?: number; onAddChild?: (parentId: number) => void; onRename?: (node: AccountNode) => void; onDelete?: (id: number) => void; }; export default function TreeNode({ node, depth = 0, onAddChild, onRename, onDelete }: Props) { const [open, setOpen] = useState(true); const hasChildren = node.children && node.children.length > 0; const indent = useMemo(() => ({ paddingLeft: `${depth * 16}px` }), [depth]); return ( <div> <div className="row" style={indent}> <button className="icon" aria-label={open ? 'Collapse' : 'Expand'} onClick={() => setOpen(!open)} disabled={!hasChildren}> {hasChildren ? (open ? '▾' : '▸') : '•'} </button> <span className="label"> <strong>{node.name}</strong> {node.code ? <span className="muted">({node.code})</span> : null} </span> <span className="spacer" /> <button className="link" onClick={() => onAddChild && onAddChild(node.id)}>+ Child</button> <button className="link" onClick={() => onRename && onRename(node)}>Rename</button> <button className="link danger" onClick={() => onDelete && onDelete(node.id)}>Delete</button> </div> {open && hasChildren && ( <div> {node.children.map(child => ( <TreeNode key={child.id} node={child} depth={depth + 1} onAddChild={onAddChild} onRename={onRename} onDelete={onDelete} /> ))} </div> )} </div> ); }
src/App.tsxtsximport React, { useEffect, useMemo, useState } from 'react'; import './app.css'; import type { AccountNode } from './types'; import TreeNode from './components/TreeNode'; import { fetchTree, createNode, updateNode, deleteNode } from './api'; type FormState = { name: string; code?: string; parentId?: number | null; sortOrder?: number; }; export default function App() { const [tree, setTree] = useState<AccountNode[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); const [filter, setFilter] = useState(''); const load = async () => { setLoading(true); setError(null); try { const data = await fetchTree(); setTree(data); } catch (e: any) { setError(e.message ?? String(e)); } finally { setLoading(false); } }; useEffect(() => { load(); }, []); const flatList = useMemo(() => { const out: AccountNode[] = []; const walk = (n: AccountNode) => { out.push(n); n.children?.forEach(walk); }; tree.forEach(walk); return out; }, [tree]); const filteredTree = useMemo(() => { if (!filter.trim()) return tree; const q = filter.toLowerCase(); const matches = new Set<number>(); const parents = new Map<number, number | null>(); // id -> parentId // Build parent map + collect matches const walk = (n: AccountNode, parentId: number | null) => { parents.set(n.id, parentId); const isMatch = n.name.toLowerCase().includes(q) || (n.code ?? '').toLowerCase().includes(q); if (isMatch) { matches.add(n.id); // bubble up parents so matched branch is kept visible let p = parentId; while (p != null) { matches.add(p); p = parents.get(p) ?? null; } } n.children?.forEach(c => walk(c, n.id)); }; tree.forEach(n => walk(n, null)); // Filter by keeping only branches that contain matched ids const keep = (n: AccountNode): AccountNode | null => { if (!matches.has(n.id)) return null; const kids = (n.children ?? []).map(keep).filter(Boolean) as AccountNode[]; return {...n, children: kids}; }; return tree.map(keep).filter(Boolean) as AccountNode[]; }, [tree, filter]); const promptNew = async (parentId?: number) => { const name = window.prompt('Account name?'); if (!name) return; const code = window.prompt('Code (optional)?') || undefined; const sort = Number(window.prompt('Sort order (integer, optional)') || '0'); await createNode({ name, code, parentId, sortOrder: isNaN(sort) ? 0 : sort }); await load(); }; const promptRename = async (node: AccountNode) => { const name = window.prompt('New name?', node.name); if (!name) return; const code = window.prompt('New code (optional)?', node.code ?? '') || undefined; const sort = Number(window.prompt('Sort order (integer, optional)', String(node.sortOrder ?? 0)) || '0'); await updateNode(node.id, { name, code, parentId: undefined, sortOrder: isNaN(sort) ? 0 : sort }); await load(); }; const confirmDelete = async (id: number) => { if (!window.confirm('Delete this account (and its children)?')) return; await deleteNode(id); await load(); }; return ( <div className="container"> <header> <h1>Account Tree</h1> <div className="toolbar"> <button onClick={() => promptNew(undefined)}>+ Root</button> <input placeholder="Filter by name or code…" value={filter} onChange={e => setFilter(e.target.value)} /> <span className="muted">{flatList.length} accounts</span> </div> </header> {loading && <div className="muted">Loading…</div>} {error && <div className="error">{error}</div>} {!loading && !error && ( <div className="tree"> {filteredTree.length === 0 ? ( <div className="muted">No accounts yet.</div> ) : ( filteredTree.map(node => ( <TreeNode key={node.id} node={node} onAddChild={(pid) => promptNew(pid)} onRename={(n) => promptRename(n)} onDelete={(id) => confirmDelete(id)} /> )) )} </div> )} <footer> <a href="http://localhost:8080/swagger-ui/index.html">Open API docs</a> </footer> </div> ); }
src/app.csscss:root { font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; } body { margin: 0; background: #fafafa; color: #222; } .container { max-width: 900px; margin: 32px auto; padding: 0 16px; } header { display: flex; align-items: center; justify-content: space-between; gap: 16px; } h1 { font-size: 22px; margin: 0; } .toolbar { display: flex; gap: 8px; align-items: center; } .toolbar input { padding: 8px 10px; border: 1px solid #ddd; border-radius: 6px; min-width: 260px; } button { padding: 6px 10px; border: 1px solid #ddd; background: white; border-radius: 6px; cursor: pointer; } button:hover { background: #f3f3f3; } button.link { border: none; background: transparent; padding: 2px 6px; cursor: pointer; } button.link:hover { text-decoration: underline; } button.link.danger { color: #b00020; } .muted { color: #6b7280; } .error { color: #b00020; background: #ffe8ec; border: 1px solid #ffd0d7; padding: 8px 12px; border-radius: 6px; } .tree { margin-top: 12px; background: white; border: 1px solid #eee; border-radius: 8px; padding: 6px; } .row { display: flex; align-items: center; gap: 8px; padding: 2px 6px; } .row:hover { background: #f9fafb; } .row .icon { width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; border: none; background: transparent; cursor: pointer; } .row .label { margin-left: 4px; } .row .spacer { flex: 1; }
Run it:
bashnpm run dev # Vite defaults to http://localhost:5173
/api/accounts/tree → AccountNode[]/api/accounts → create node{ name, code?, parentId?, sortOrder? }/api/accounts/{id} → update node{ name, code?, parentId?, sortOrder? } (omit parentId to keep current)/api/accounts/{id} → delete node (children cascade)@CrossOrigin with a proper CORS config.GET /api/accounts/children?parentId=...) and render nodes on demand.If you want, tell me your DB creds/host and I’ll inline a ready-to-run docker-compose.yml for MySQL + the API + the UI.
Create a zip file for the above so that I can download it
add register and login page for the above code showing the name of the person after logging in. also create product tree.
All set! I added:
You can download the complete, ready-to-run project here:
[Download the project zip]
backend/src/main/resources/application.properties and set your MySQL username, password, and a strong app.jwt.secret.bashcd backend mvn spring-boot:run
http://localhost:8080 (Swagger at /swagger-ui/index.html)bashcd frontend npm install npm run dev # open http://localhost:5173
If you want Docker or a Postman collection, say the word and I’ll bundle it into the zip too.
[Download the project zip]. i clicked on it. its not downloading
i did not as for vue. i need reactjs type script. just provide me code without zip
Got it — here’s a React (TypeScript) + Spring Boot + MySQL project with Register/Login (JWT), user name shown after login, and both Account & Product trees. Copy these files into a fresh workspace. I’ve kept things lean but production-friendly.
pom.xmlxml<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>tree-auth-api</artifactId> <version>0.0.1</version> <properties> <java.version>17</java.version> <spring.boot.version>3.3.2</spring.boot.version> <jjwt.version>0.11.5</jjwt.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring.boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- MySQL --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- JWT --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>${jjwt.version}</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>${jjwt.version}</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>${jjwt.version}</version> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- Swagger (optional) --> <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.6.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
src/main/resources/application.propertiespropertiesspring.datasource.url=jdbc:mysql://localhost:3306/treeapp?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC spring.datasource.username=your_user spring.datasource.password=your_password spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=false spring.jpa.properties.hibernate.format_sql=true # CORS for local dev app.cors.allowed-origins=http://localhost:5173,http://localhost:3000 # JWT app.jwt.secret=CHANGE_ME_TO_A_LONG_RANDOM_STRING app.jwt.ttl-seconds=86400
User, Account, Productcom/example/treeauthapi/model/User.java
javapackage com.example.treeauthapi.model; import jakarta.persistence.*; import lombok.*; @Entity @Table(name="users", uniqueConstraints = @UniqueConstraint(columnNames = "email")) @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable=false) private String displayName; @Column(nullable=false, unique=true) private String email; @Column(nullable=false) private String passwordHash; // BCrypt }
Account.java
javapackage com.example.treeauthapi.model; import jakarta.persistence.*; import lombok.*; @Entity @Table(name="accounts") @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder public class Account { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable=false) private String name; private String code; private Integer sortOrder = 0; @ManyToOne(fetch=FetchType.LAZY) @JoinColumn(name="parent_id") private Account parent; }
Product.java
javapackage com.example.treeauthapi.model; import jakarta.persistence.*; import lombok.*; @Entity @Table(name="products") @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable=false) private String name; private String sku; private Integer sortOrder = 0; @ManyToOne(fetch=FetchType.LAZY) @JoinColumn(name="parent_id") private Product parent; }
dto/AccountNode.java
javapackage com.example.treeauthapi.dto; import java.util.ArrayList; import java.util.List; public record AccountNode(Long id, String name, String code, Integer sortOrder, List<AccountNode> children) { public static AccountNode of(Long id, String name, String code, Integer sortOrder){ return new AccountNode(id, name, code, sortOrder, new ArrayList<>()); } }
dto/ProductNode.java
javapackage com.example.treeauthapi.dto; import java.util.ArrayList; import java.util.List; public record ProductNode(Long id, String name, String sku, Integer sortOrder, List<ProductNode> children) { public static ProductNode of(Long id, String name, String sku, Integer sortOrder){ return new ProductNode(id, name, sku, sortOrder, new ArrayList<>()); } }
dto/CreateUpdateAccountDto.java
javapackage com.example.treeauthapi.dto; import jakarta.validation.constraints.NotBlank; public record CreateUpdateAccountDto( @NotBlank String name, String code, Long parentId, Integer sortOrder ) {}
dto/CreateUpdateProductDto.java
javapackage com.example.treeauthapi.dto; import jakarta.validation.constraints.NotBlank; public record CreateUpdateProductDto( @NotBlank String name, String sku, Long parentId, Integer sortOrder ) {}
dto/AuthDtos.java
javapackage com.example.treeauthapi.dto; import jakarta.validation.constraints.*; public class AuthDtos { public record RegisterRequest( @NotBlank String displayName, @Email @NotBlank String email, @Size(min=6) String password ){} public record LoginRequest(@Email @NotBlank String email, @NotBlank String password){} public record AuthResponse(String token, String displayName, String email){} }
java// repo/UserRepository.java package com.example.treeauthapi.repo; import com.example.treeauthapi.model.User; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByEmail(String email); }
java// repo/AccountRepository.java package com.example.treeauthapi.repo; import com.example.treeauthapi.model.Account; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface AccountRepository extends JpaRepository<Account, Long> { List<Account> findAllByOrderBySortOrderAscNameAsc(); }
java// repo/ProductRepository.java package com.example.treeauthapi.repo; import com.example.treeauthapi.model.Product; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface ProductRepository extends JpaRepository<Product, Long> { List<Product> findAllByOrderBySortOrderAscNameAsc(); }
java// service/AccountService.java package com.example.treeauthapi.service; import com.example.treeauthapi.dto.AccountNode; import com.example.treeauthapi.dto.CreateUpdateAccountDto; import com.example.treeauthapi.model.Account; import com.example.treeauthapi.repo.AccountRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.*; @Service public class AccountService { private final AccountRepository repo; public AccountService(AccountRepository repo){ this.repo = repo; } @Transactional(readOnly = true) public List<AccountNode> getTree(){ List<Account> all = repo.findAllByOrderBySortOrderAscNameAsc(); Map<Long, AccountNode> byId = new HashMap<>(); for (Account a : all) byId.put(a.getId(), AccountNode.of(a.getId(), a.getName(), a.getCode(), a.getSortOrder())); List<AccountNode> roots = new ArrayList<>(); for (Account a : all) { AccountNode n = byId.get(a.getId()); if (a.getParent()==null) roots.add(n); else byId.get(a.getParent().getId()).children().add(n); } return roots; } @Transactional public Account create(CreateUpdateAccountDto dto){ Account e = new Account(); apply(e, dto); return repo.save(e); } @Transactional public Account update(Long id, CreateUpdateAccountDto dto){ Account e = repo.findById(id).orElseThrow(); apply(e, dto); return repo.save(e); } @Transactional public void delete(Long id){ repo.deleteById(id); } private void apply(Account e, CreateUpdateAccountDto dto){ e.setName(dto.name()); e.setCode(dto.code()); e.setSortOrder(dto.sortOrder()==null?0:dto.sortOrder()); if (dto.parentId()==null) e.setParent(null); else e.setParent(repo.findById(dto.parentId()).orElseThrow()); if (e.getId()!=null && dto.parentId()!=null && e.getId().equals(dto.parentId())) throw new IllegalArgumentException("Node cannot be its own parent"); } }
java// service/ProductService.java package com.example.treeauthapi.service; import com.example.treeauthapi.dto.CreateUpdateProductDto; import com.example.treeauthapi.dto.ProductNode; import com.example.treeauthapi.model.Product; import com.example.treeauthapi.repo.ProductRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.*; @Service public class ProductService { private final ProductRepository repo; public ProductService(ProductRepository repo){ this.repo = repo; } @Transactional(readOnly = true) public List<ProductNode> getTree(){ List<Product> all = repo.findAllByOrderBySortOrderAscNameAsc(); Map<Long, ProductNode> byId = new HashMap<>(); for (Product p: all) byId.put(p.getId(), ProductNode.of(p.getId(), p.getName(), p.getSku(), p.getSortOrder())); List<ProductNode> roots = new ArrayList<>(); for (Product p: all){ ProductNode n = byId.get(p.getId()); if (p.getParent()==null) roots.add(n); else byId.get(p.getParent().getId()).children().add(n); } return roots; } @Transactional public Product create(CreateUpdateProductDto dto){ Product e = new Product(); apply(e, dto); return repo.save(e); } @Transactional public Product update(Long id, CreateUpdateProductDto dto){ Product e = repo.findById(id).orElseThrow(); apply(e, dto); return repo.save(e); } @Transactional public void delete(Long id){ repo.deleteById(id); } private void apply(Product e, CreateUpdateProductDto dto){ e.setName(dto.name()); e.setSku(dto.sku()); e.setSortOrder(dto.sortOrder()==null?0:dto.sortOrder()); if (dto.parentId()==null) e.setParent(null); else e.setParent(repo.findById(dto.parentId()).orElseThrow()); if (e.getId()!=null && dto.parentId()!=null && e.getId().equals(dto.parentId())) throw new IllegalArgumentException("Node cannot be its own parent"); } }
java// security/JwtUtil.java package com.example.treeauthapi.security; import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import org.springframework.stereotype.Component; import java.security.Key; import java.util.Date; @Component public class JwtUtil { private final Key key; private final long ttl; public JwtUtil(org.springframework.core.env.Environment env){ String secret = env.getProperty("app.jwt.secret", "change_me"); this.key = Keys.hmacShaKeyFor(secret.getBytes()); this.ttl = Long.parseLong(env.getProperty("app.jwt.ttl-seconds","86400")); } public String create(String email, String displayName){ long now = System.currentTimeMillis(); return Jwts.builder() .setSubject(email) .claim("name", displayName) .setIssuedAt(new Date(now)) .setExpiration(new Date(now + ttl*1000)) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public Jws<Claims> parse(String token){ return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); } }
java// security/JwtAuthFilter.java package com.example.treeauthapi.security; import io.jsonwebtoken.Claims; import jakarta.servlet.*; import jakarta.servlet.http.*; import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.List; @Component public class JwtAuthFilter extends OncePerRequestFilter { private final JwtUtil jwt; public JwtAuthFilter(JwtUtil jwt){ this.jwt = jwt; } @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException { String auth = req.getHeader(HttpHeaders.AUTHORIZATION); if (auth != null && auth.startsWith("Bearer ")) { try { var jws = jwt.parse(auth.substring(7)); Claims c = jws.getBody(); var authToken = new UsernamePasswordAuthenticationToken( c.getSubject(), null, List.of()); // roles omitted authToken.setDetails(c); SecurityContextHolder.getContext().setAuthentication(authToken); } catch (Exception ignored) { } } chain.doFilter(req, res); } }
java// security/SecurityConfig.java package com.example.treeauthapi.security; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration public class SecurityConfig { @Bean BCryptPasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthFilter jwt) throws Exception { http.csrf(csrf -> csrf.disable()); http.cors(c -> {}); http.authorizeHttpRequests(auth -> auth .requestMatchers("/swagger-ui/**","/v3/api-docs/**").permitAll() .requestMatchers("/api/auth/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/accounts/tree","/api/products/tree").authenticated() .requestMatchers("/api/accounts/**","/api/products/**").authenticated() .anyRequest().permitAll() ); http.addFilterBefore(jwt, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception { return cfg.getAuthenticationManager(); } }
java// service/AuthService.java package com.example.treeauthapi.service; import com.example.treeauthapi.dto.AuthDtos.*; import com.example.treeauthapi.model.User; import com.example.treeauthapi.repo.UserRepository; import com.example.treeauthapi.security.JwtUtil; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; @Service public class AuthService { private final UserRepository users; private final BCryptPasswordEncoder encoder; private final JwtUtil jwt; public AuthService(UserRepository users, BCryptPasswordEncoder encoder, JwtUtil jwt){ this.users = users; this.encoder = encoder; this.jwt = jwt; } public AuthResponse register(RegisterRequest req){ users.findByEmail(req.email()).ifPresent(u -> { throw new IllegalArgumentException("Email already registered"); }); User u = User.builder() .displayName(req.displayName()) .email(req.email()) .passwordHash(encoder.encode(req.password())) .build(); users.save(u); String token = jwt.create(u.getEmail(), u.getDisplayName()); return new AuthResponse(token, u.getDisplayName(), u.getEmail()); } public AuthResponse login(LoginRequest req){ User u = users.findByEmail(req.email()).orElseThrow(() -> new IllegalArgumentException("Invalid credentials")); if (!encoder.matches(req.password(), u.getPasswordHash())) throw new IllegalArgumentException("Invalid credentials"); String token = jwt.create(u.getEmail(), u.getDisplayName()); return new AuthResponse(token, u.getDisplayName(), u.getEmail()); } }
java// web/AuthController.java package com.example.treeauthapi.web; import com.example.treeauthapi.dto.AuthDtos.*; import com.example.treeauthapi.service.AuthService; import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/auth") @CrossOrigin(origins = {"http://localhost:5173","http://localhost:3000"}) public class AuthController { private final AuthService svc; public AuthController(AuthService svc){ this.svc = svc; } @PostMapping("/register") public AuthResponse register(@RequestBody @Valid RegisterRequest req){ return svc.register(req); } @PostMapping("/login") public AuthResponse login(@RequestBody @Valid LoginRequest req){ return svc.login(req); } }
java// web/AccountController.java package com.example.treeauthapi.web; import com.example.treeauthapi.dto.AccountNode; import com.example.treeauthapi.dto.CreateUpdateAccountDto; import com.example.treeauthapi.model.Account; import com.example.treeauthapi.service.AccountService; import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/api/accounts") @CrossOrigin(origins = {"http://localhost:5173","http://localhost:3000"}) public class AccountController { private final AccountService svc; public AccountController(AccountService svc){ this.svc = svc; } @GetMapping("/tree") public List<AccountNode> tree(){ return svc.getTree(); } @PostMapping public Account create(@RequestBody @Valid CreateUpdateAccountDto dto){ return svc.create(dto); } @PutMapping("/{id}") public Account update(@PathVariable Long id, @RequestBody @Valid CreateUpdateAccountDto dto){ return svc.update(id, dto); } @DeleteMapping("/{id}") public void delete(@PathVariable Long id){ svc.delete(id); } }
java// web/ProductController.java package com.example.treeauthapi.web; import com.example.treeauthapi.dto.CreateUpdateProductDto; import com.example.treeauthapi.dto.ProductNode; import com.example.treeauthapi.model.Product; import com.example.treeauthapi.service.ProductService; import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/api/products") @CrossOrigin(origins = {"http://localhost:5173","http://localhost:3000"}) public class ProductController { private final ProductService svc; public ProductController(ProductService svc){ this.svc = svc; } @GetMapping("/tree") public List<ProductNode> tree(){ return svc.getTree(); } @PostMapping public Product create(@RequestBody @Valid CreateUpdateProductDto dto){ return svc.create(dto); } @PutMapping("/{id}") public Product update(@PathVariable Long id, @RequestBody @Valid CreateUpdateProductDto dto){ return svc.update(id, dto); } @DeleteMapping("/{id}") public void delete(@PathVariable Long id){ svc.delete(id); } }
java// TreeAuthApiApplication.java package com.example.treeauthapi; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class TreeAuthApiApplication { public static void main(String[] args){ SpringApplication.run(TreeAuthApiApplication.class, args); } }
Run backend:
bashmvn spring-boot:run # http://localhost:8080
bashnpm create vite@latest tree-ui -- --template react-ts cd tree-ui npm install
src/types.tstsexport type AccountNode = { id: number; name: string; code?: string | null; sortOrder?: number | null; children: AccountNode[]; }; export type ProductNode = { id: number; name: string; sku?: string | null; sortOrder?: number | null; children: ProductNode[]; }; export type AuthUser = { displayName: string; email: string; token: string };
src/api.tstsconst API = 'http://localhost:8080/api'; let _token: string | null = localStorage.getItem('token'); export function setToken(t: string | null){ _token = t; if (t) localStorage.setItem('token', t); else localStorage.removeItem('token'); } export function authHeader() { return _token ? { Authorization: `Bearer ${_token}` } : {}; } // AUTH export async function register(displayName: string, email: string, password: string){ const r = await fetch(`${API}/auth/register`, { method:'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ displayName, email, password }) }); if (!r.ok) throw new Error(await r.text()); return r.json() as Promise<{ token: string; displayName: string; email: string }>; } export async function login(email: string, password: string){ const r = await fetch(`${API}/auth/login`, { method:'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ email, password }) }); if (!r.ok) throw new Error('Invalid credentials'); return r.json() as Promise<{ token: string; displayName: string; email: string }>; } // ACCOUNTS export async function fetchAccountTree(){ const r = await fetch(`${API}/accounts/tree`, { headers: { ...authHeader() }}); if (!r.ok) throw new Error('Failed to load account tree'); return r.json(); } export async function createAccount(payload: { name: string; code?: string; parentId?: number | null; sortOrder?: number }){ const r = await fetch(`${API}/accounts`, { method:'POST', headers: { 'Content-Type':'application/json', ...authHeader() }, body: JSON.stringify(payload) }); if (!r.ok) throw new Error('Create account failed'); return r.json(); } export async function updateAccount(id: number, payload: { name: string; code?: string; parentId?: number | null; sortOrder?: number }){ const r = await fetch(`${API}/accounts/${id}`, { method:'PUT', headers: { 'Content-Type':'application/json', ...authHeader() }, body: JSON.stringify(payload) }); if (!r.ok) throw new Error('Update account failed'); return r.json(); } export async function deleteAccount(id: number){ const r = await fetch(`${API}/accounts/${id}`, { method:'DELETE', headers: { ...authHeader() }}); if (!r.ok) throw new Error('Delete account failed'); } // PRODUCTS export async function fetchProductTree(){ const r = await fetch(`${API}/products/tree`, { headers: { ...authHeader() }}); if (!r.ok) throw new Error('Failed to load product tree'); return r.json(); } export async function createProduct(payload: { name: string; sku?: string; parentId?: number | null; sortOrder?: number }){ const r = await fetch(`${API}/products`, { method:'POST', headers: { 'Content-Type':'application/json', ...authHeader() }, body: JSON.stringify(payload) }); if (!r.ok) throw new Error('Create product failed'); return r.json(); } export async function updateProduct(id: number, payload: { name: string; sku?: string; parentId?: number | null; sortOrder?: number }){ const r = await fetch(`${API}/products/${id}`, { method:'PUT', headers: { 'Content-Type':'application/json', ...authHeader() }, body: JSON.stringify(payload) }); if (!r.ok) throw new Error('Update product failed'); return r.json(); } export async function deleteProduct(id: number){ const r = await fetch(`${API}/products/${id}`, { method:'DELETE', headers: { ...authHeader() }}); if (!r.ok) throw new Error('Delete product failed'); }
src/AuthContext.tsxtsximport React, { createContext, useContext, useEffect, useState } from 'react'; import { setToken } from './api'; import type { AuthUser } from './types'; type Ctx = { user: AuthUser | null; login: (u: AuthUser) => void; logout: () => void; }; const AuthCtx = createContext<Ctx>({ user: null, login: () => {}, logout: () => {} }); export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState<AuthUser | null>(null); useEffect(() => { const token = localStorage.getItem('token'); const displayName = localStorage.getItem('displayName'); const email = localStorage.getItem('email'); if (token && displayName && email) { setUser({ token, displayName, email }); setToken(token); } }, []); const login = (u: AuthUser) => { setUser(u); setToken(u.token); localStorage.setItem('displayName', u.displayName); localStorage.setItem('email', u.email); }; const logout = () => { setUser(null); setToken(null); localStorage.removeItem('displayName'); localStorage.removeItem('email'); }; return <AuthCtx.Provider value={{ user, login, logout }}>{children}</AuthCtx.Provider>; } export function useAuth(){ return useContext(AuthCtx); }
src/components/Header.tsxtsximport React from 'react'; import { useAuth } from '../AuthContext'; export default function Header(){ const { user, logout } = useAuth(); return ( <header className="hdr"> <h1>Tree Manager</h1> <div className="grow" /> {user ? ( <> <span className="muted">Signed in as <strong>{user.displayName}</strong></span> <button onClick={logout}>Logout</button> </> ) : null} </header> ); }
src/components/TreeNode.tsxtsximport React, { useMemo, useState } from 'react'; type NodeT = { id: number; name: string; sortOrder?: number | null; children: NodeT[]; code?: string | null; sku?: string | null; }; type Props = { node: NodeT; depth?: number; onAddChild: (parentId: number) => void; onRename: (node: NodeT) => void; onDelete: (id: number) => void; tag?: 'code'|'sku'|null; }; export default function TreeNode({ node, depth=0, onAddChild, onRename, onDelete, tag=null }: Props){ const [open,setOpen] = useState(true); const hasChildren = node.children?.length>0; const indent = useMemo(()=>({ paddingLeft: `${depth*16}px` }),[depth]); const tagVal = tag==='code'? node.code : tag==='sku'? node.sku : undefined; return ( <div> <div className="row" style={indent}> <button className="icon" disabled={!hasChildren} onClick={()=>setOpen(!open)}>{hasChildren ? (open?'▾':'▸') : '•'}</button> <span><strong>{node.name}</strong>{tagVal? <span className="muted"> ({tagVal})</span>:null}</span> <span className="spacer" /> <button className="link" onClick={()=>onAddChild(node.id)}>+ Child</button> <button className="link" onClick={()=>onRename(node)}>Rename</button> <button className="link danger" onClick={()=>onDelete(node.id)}>Delete</button> </div> {open && hasChildren && node.children.map(c => <TreeNode key={c.id} node={c} depth={depth+1} onAddChild={onAddChild} onRename={onRename} onDelete={onDelete} tag={tag}/> )} </div> ); }
src/pages/Login.tsxtsximport React, { useState } from 'react'; import { login } from '../api'; import { useAuth } from '../AuthContext'; export default function LoginPage(){ const { login: setAuth } = useAuth(); const [email,setEmail] = useState(''); const [password,setPassword]=useState(''); const [err,setErr]=useState<string|null>(null); const submit = async (e:React.FormEvent)=> { e.preventDefault(); try{ setErr(null); const res = await login(email,password); setAuth({ token: res.token, displayName: res.displayName, email: res.email }); }catch(e:any){ setErr(e.message||'Login failed'); } }; return ( <div className="authcard"> <h2>Login</h2> {err && <div className="error">{err}</div>} <form onSubmit={submit}> <input placeholder="Email" value={email} onChange={e=>setEmail(e.target.value)} /> <input placeholder="Password" type="password" value={password} onChange={e=>setPassword(e.target.value)} /> <button type="submit">Login</button> </form> </div> ); }
src/pages/Register.tsxtsximport React, { useState } from 'react'; import { register } from '../api'; import { useAuth } from '../AuthContext'; export default function RegisterPage(){ const { login: setAuth } = useAuth(); const [displayName,setDisplayName]=useState(''); const [email,setEmail]=useState(''); const [password,setPassword]=useState(''); const [err,setErr]=useState<string|null>(null); const submit = async (e:React.FormEvent)=> { e.preventDefault(); try{ setErr(null); const res = await register(displayName,email,password); setAuth({ token: res.token, displayName: res.displayName, email: res.email }); }catch(e:any){ setErr(e.message||'Register failed'); } }; return ( <div className="authcard"> <h2>Register</h2> {err && <div className="error">{err}</div>} <form onSubmit={submit}> <input placeholder="Display name" value={displayName} onChange={e=>setDisplayName(e.target.value)} /> <input placeholder="Email" value={email} onChange={e=>setEmail(e.target.value)} /> <input placeholder="Password" type="password" value={password} onChange={e=>setPassword(e.target.value)} /> <button type="submit">Create account</button> </form> </div> ); }
src/pages/Trees.tsx (Accounts & Products tabs)tsximport React, { useEffect, useMemo, useState } from 'react'; import TreeNode from '../components/TreeNode'; import { AccountNode, ProductNode } from '../types'; import { fetchAccountTree, createAccount, updateAccount, deleteAccount, fetchProductTree, createProduct, updateProduct, deleteProduct } from '../api'; type Tab = 'accounts'|'products'; export default function TreesPage(){ const [tab,setTab] = useState<Tab>('accounts'); const [accTree,setAccTree] = useState<AccountNode[]>([]); const [prodTree,setProdTree] = useState<ProductNode[]>([]); const [filter,setFilter] = useState(''); const loadAcc = async()=> setAccTree(await fetchAccountTree()); const loadProd = async()=> setProdTree(await fetchProductTree()); useEffect(()=>{ loadAcc(); loadProd(); }, []); const filterTree = <T extends { id:number; name:string; children:T[]; }>(tree:T[], q:string):T[]=>{ if (!q.trim()) return tree; const needle = q.toLowerCase(); const keep = (n:T):T|null=>{ const kids = (n.children||[]).map(c=>keep(c)).filter(Boolean) as T[]; if (n.name.toLowerCase().includes(needle) || kids.length>0) return { ...n, children: kids }; // @ts-ignore small hack to allow optional code/sku search const tag = (n.code||n.sku||'') as string; if (tag.toLowerCase().includes(needle)) return { ...n, children: kids }; return null; }; return tree.map(n=>keep(n)).filter(Boolean) as T[]; }; const accFiltered = useMemo(()=>filterTree(accTree, filter),[accTree, filter]); const prodFiltered = useMemo(()=>filterTree(prodTree, filter),[prodTree, filter]); // Accounts const addAcc = async(parentId?:number)=> { const name = prompt('Account name?'); if (!name) return; const code = prompt('Code (optional)')||undefined; const sort = Number(prompt('Sort order (optional)')||'0'); await createAccount({ name, code, parentId, sortOrder: isNaN(sort)?0:sort }); await loadAcc(); }; const renAcc = async(node:AccountNode)=>{ const name = prompt('New name', node.name)||node.name; const code = prompt('New code', node.code||'')||undefined; const sort = Number(prompt('Sort order', String(node.sortOrder??0))||'0'); await updateAccount(node.id, { name, code, sortOrder: isNaN(sort)?0:sort }); await loadAcc(); }; const delAcc = async(id:number)=> { if (confirm('Delete account and children?')) { await deleteAccount(id); await loadAcc(); } }; // Products const addProd = async(parentId?:number)=> { const name = prompt('Product name?'); if (!name) return; const sku = prompt('SKU (optional)')||undefined; const sort = Number(prompt('Sort order (optional)')||'0'); await createProduct({ name, sku, parentId, sortOrder: isNaN(sort)?0:sort }); await loadProd(); }; const renProd = async(node:ProductNode)=>{ const name = prompt('New name', node.name)||node.name; const sku = prompt('New SKU', node.sku||'')||undefined; const sort = Number(prompt('Sort order', String(node.sortOrder??0))||'0'); await updateProduct(node.id, { name, sku, sortOrder: isNaN(sort)?0:sort }); await loadProd(); }; const delProd = async(id:number)=> { if (confirm('Delete product and children?')) { await deleteProduct(id); await loadProd(); } }; return ( <div> <div className="tabs"> <button className={tab==='accounts'?'active':''} onClick={()=>setTab('accounts')}>Accounts</button> <button className={tab==='products'?'active':''} onClick={()=>setTab('products')}>Products</button> <input className="filter" placeholder="Filter…" value={filter} onChange={e=>setFilter(e.target.value)} /> {tab==='accounts' ? <button onClick={()=>addAcc(undefined)}>+ Root Account</button> : <button onClick={()=>addProd(undefined)}>+ Root Product</button>} </div> {tab==='accounts' ? ( <div className="tree"> {accFiltered.map(n => <TreeNode key={n.id} node={n} onAddChild={(pid)=>addAcc(pid)} onRename={renAcc} onDelete={delAcc} tag="code"/> )} </div> ) : ( <div className="tree"> {prodFiltered.map(n => <TreeNode key={n.id} node={n} onAddChild={(pid)=>addProd(pid)} onRename={renProd} onDelete={delProd} tag="sku"/> )} </div> )} </div> ); }
src/App.tsxtsximport React from 'react'; import './app.css'; import { AuthProvider, useAuth } from './AuthContext'; import Header from './components/Header'; import LoginPage from './pages/Login'; import RegisterPage from './pages/Register'; import TreesPage from './pages/Trees'; function Main(){ const { user } = useAuth(); const [mode,setMode] = React.useState<'login'|'register'|'app'>(user?'app':'login'); React.useEffect(()=>{ if (user) setMode('app'); }, [user]); return ( <div className="container"> <Header/> {mode==='app' && <TreesPage/>} {mode!=='app' && ( <div className="authswitch"> <div className="switch"> <button className={mode==='login'?'active':''} onClick={()=>setMode('login')}>Login</button> <button className={mode==='register'?'active':''} onClick={()=>setMode('register')}>Register</button> </div> {mode==='login' ? <LoginPage/> : <RegisterPage/>} </div> )} </div> ); } export default function App(){ return <AuthProvider><Main/></AuthProvider>; }
src/app.csscss:root { font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; } body { margin: 0; background: #fafafa; color: #222; } .container { max-width: 980px; margin: 24px auto; padding: 0 16px; } .hdr { display: flex; align-items: center; gap: 12px; } .hdr .grow { flex: 1; } button { padding: 6px 10px; border: 1px solid #ddd; background: white; border-radius: 6px; cursor: pointer; } button:hover { background: #f3f3f3; } button.active { background: #eef; } .muted { color: #6b7280; } .error { color: #b00020; background: #ffe8ec; border: 1px solid #ffd0d7; padding: 8px 12px; border-radius: 6px; } .tabs { display: flex; gap: 8px; align-items: center; margin: 12px 0; } .tabs .filter { margin-left: auto; padding: 8px 10px; border: 1px solid #ddd; border-radius: 6px; min-width: 240px; } .tree { background: white; border: 1px solid #eee; border-radius: 8px; padding: 6px; } .row { display: flex; align-items: center; gap: 8px; padding: 2px 6px; } .row:hover { background: #f9fafb; } .row .icon { width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; border: none; background: transparent; } .row .spacer { flex: 1; } button.link { border: none; background: none; padding: 2px 6px; cursor: pointer; } button.link:hover { text-decoration: underline; } button.link.danger { color: #b00020; } .authswitch .switch { display:flex; gap:8px; margin: 16px 0; } .authcard { background:white; border:1px solid #eee; border-radius:8px; padding:16px; max-width:360px; } .authcard form { display:flex; flex-direction:column; gap:8px; } .authcard input { padding: 8px 10px; border: 1px solid #ddd; border-radius: 6px; }
Run frontend:
bashnpm run dev # open http://localhost:5173
If you prefer to create tables yourself (instead of JPA ddl-auto=update), use:
sqlCREATE TABLE users ( id BIGINT PRIMARY KEY AUTO_INCREMENT, display_name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL ); CREATE TABLE accounts ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255) NOT NULL, code VARCHAR(64), sort_order INT DEFAULT 0, parent_id BIGINT, CONSTRAINT fk_acc_parent FOREIGN KEY (parent_id) REFERENCES accounts(id) ON DELETE CASCADE ); CREATE INDEX idx_acc_parent ON accounts(parent_id); CREATE TABLE products ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255) NOT NULL, sku VARCHAR(64), sort_order INT DEFAULT 0, parent_id BIGINT, CONSTRAINT fk_prod_parent FOREIGN KEY (parent_id) REFERENCES products(id) ON DELETE CASCADE ); CREATE INDEX idx_prod_parent ON products(parent_id);
POST /api/auth/register → {displayName,email,password} → {token,displayName,email}POST /api/auth/login → {email,password} → {token,displayName,email}Bearer <token> for all below:
GET /api/accounts/treePOST /api/accounts {name,code?,parentId?,sortOrder?}PUT /api/accounts/{id} dittoDELETE /api/accounts/{id}GET /api/products/treePOST /api/products {name,sku?,parentId?,sortOrder?}PUT /api/products/{id}DELETE /api/products/{id}app.jwt.secret to a long random string.http://localhost:5173 and :3000 for dev.create the database name as erpsoft. tables to be created by entity relationship. also i need voucher such as payment request, cash receipt, cash payment, bank receipt and bank payment. sales invoice with saudi phase 2 integration. purchase voucher
Awesome — I’ll extend the React (TypeScript) + Spring Boot + MySQL project you already have to:
erpsoft,Below are only the code files you need (drop-in additions/edits). I kept names consistent with the code I shared earlier.
src/main/resources/application.properties (DB = erpsoft)propertiesspring.datasource.url=jdbc:mysql://localhost:3306/erpsoft?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC spring.datasource.username=your_user spring.datasource.password=your_password spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=false spring.jpa.properties.hibernate.format_sql=true app.cors.allowed-origins=http://localhost:5173,http://localhost:3000 # JWT app.jwt.secret=CHANGE_ME_TO_A_LONG_RANDOM_STRING app.jwt.ttl-seconds=86400
With
ddl-auto=update, Hibernate builds tables from entities automatically.
model/Contact.java
javapackage com.example.treeauthapi.model; import jakarta.persistence.*; import lombok.*; @Entity @Table(name="contacts") @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder public class Contact { @Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id; @Column(nullable=false) private String name; private String vatNumber; // KSA VAT TRN for ZATCA @Enumerated(EnumType.STRING) @Column(nullable=false) private ContactType type; // CUSTOMER or SUPPLIER public enum ContactType { CUSTOMER, SUPPLIER } }
model/CompanySettings.java
javapackage com.example.treeauthapi.model; import jakarta.persistence.*; import lombok.*; @Entity @Table(name="company_settings") @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder public class CompanySettings { @Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id; @Column(nullable=false) private String legalName; @Column(nullable=false) private String vatNumber; // Seller TRN private String addressLine; private String city; private String countryCode; // "SA" // ZATCA Phase 2 device & crypto placeholders private String csid; // Compliance CSID (device identifier) private String productionMode; // "sandbox" | "production" @Lob private String privateKeyPem; // ECDSA private key (if held) @Lob private String certificatePem; // Device certificate }
Repos
java// repo/ContactRepository.java package com.example.treeauthapi.repo; import com.example.treeauthapi.model.Contact; import org.springframework.data.jpa.repository.JpaRepository; public interface ContactRepository extends JpaRepository<Contact, Long> {} // repo/CompanySettingsRepository.java package com.example.treeauthapi.repo; import com.example.treeauthapi.model.CompanySettings; import org.springframework.data.jpa.repository.JpaRepository; public interface CompanySettingsRepository extends JpaRepository<CompanySettings, Long> {}
Controller (simple)
java// web/CompanySettingsController.java package com.example.treeauthapi.web; import com.example.treeauthapi.model.CompanySettings; import com.example.treeauthapi.repo.CompanySettingsRepository; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/company") @CrossOrigin(origins = {"http://localhost:5173","http://localhost:3000"}) public class CompanySettingsController { private final CompanySettingsRepository repo; public CompanySettingsController(CompanySettingsRepository repo){ this.repo = repo; } @GetMapping public CompanySettings get() { return repo.findAll().stream().findFirst().orElse(null); } @PutMapping public CompanySettings put(@RequestBody CompanySettings s){ if (s.getId()==null && !repo.findAll().isEmpty()) s.setId(repo.findAll().get(0).getId()); return repo.save(s); } }
We’ll use a single Voucher entity with a type enum and lines (double-entry style). “Cash” vs “Bank” is in channel.
model/Voucher.java
javapackage com.example.treeauthapi.model; import jakarta.persistence.*; import lombok.*; import java.time.LocalDate; import java.util.*; @Entity @Table(name="vouchers") @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder public class Voucher { @Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id; @Enumerated(EnumType.STRING) @Column(nullable=false) private VoucherType type; public enum VoucherType { PAYMENT_REQUEST, CASH_RECEIPT, CASH_PAYMENT, BANK_RECEIPT, BANK_PAYMENT, PURCHASE } @Enumerated(EnumType.STRING) @Column(nullable=false) private VoucherStatus status; public enum VoucherStatus { DRAFT, POSTED } @Column(nullable=false) private LocalDate date; @Column(nullable=false, unique=true) private String number; // human ref @ManyToOne(fetch=FetchType.LAZY) @JoinColumn(name="contact_id") private Contact contact; // customer/supplier as relevant @Enumerated(EnumType.STRING) @Column(nullable=false) private Channel channel; public enum Channel { CASH, BANK, NONE } // NONE for payment request private String bankName; private String bankAccountNo; // for BANK channel private String narration; @OneToMany(mappedBy="voucher", cascade=CascadeType.ALL, orphanRemoval=true, fetch=FetchType.EAGER) private List<VoucherLine> lines = new ArrayList<>(); }
model/VoucherLine.java
javapackage com.example.treeauthapi.model; import jakarta.persistence.*; import lombok.*; @Entity @Table(name="voucher_lines") @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder public class VoucherLine { @Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id; @ManyToOne(fetch=FetchType.LAZY) @JoinColumn(name="voucher_id") private Voucher voucher; @ManyToOne(fetch=FetchType.LAZY) @JoinColumn(name="account_id") private Account account; private String description; // debit/credit signed amounts; positive = debit, negative = credit @Column(nullable=false) private Double amount; private Double taxAmount; // optional }
Repos
java// repo/VoucherRepository.java package com.example.treeauthapi.repo; import com.example.treeauthapi.model.Voucher; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface VoucherRepository extends JpaRepository<Voucher, Long> { List<Voucher> findByTypeOrderByDateDescIdDesc(Voucher.VoucherType type); }
DTOs
java// dto/VoucherDtos.java package com.example.treeauthapi.dto; import jakarta.validation.constraints.NotNull; import java.time.LocalDate; import java.util.List; public class VoucherDtos { public record Line(Long accountId, String description, double amount, Double taxAmount){} public record CreateUpdate( @NotNull String number, @NotNull LocalDate date, @NotNull String type, // enum name String status, // default DRAFT String channel, // CASH|BANK|NONE Long contactId, String bankName, String bankAccountNo, String narration, List<Line> lines ){} }
Service
java// service/VoucherService.java package com.example.treeauthapi.service; import com.example.treeauthapi.dto.VoucherDtos.CreateUpdate; import com.example.treeauthapi.dto.VoucherDtos.Line; import com.example.treeauthapi.model.*; import com.example.treeauthapi.repo.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; @Service public class VoucherService { private final VoucherRepository vouchers; private final AccountRepository accounts; private final ContactRepository contacts; public VoucherService(VoucherRepository v, AccountRepository a, ContactRepository c){ this.vouchers = v; this.accounts = a; this.contacts = c; } @Transactional(readOnly = true) public List<Voucher> listByType(Voucher.VoucherType type){ return vouchers.findByTypeOrderByDateDescIdDesc(type); } @Transactional public Voucher create(CreateUpdate dto){ Voucher v = new Voucher(); apply(v, dto); return vouchers.save(v); } @Transactional public Voucher update(Long id, CreateUpdate dto){ Voucher v = vouchers.findById(id).orElseThrow(); v.getLines().clear(); apply(v, dto); return vouchers.save(v); } @Transactional public void delete(Long id){ vouchers.deleteById(id); } private void apply(Voucher v, CreateUpdate dto){ v.setNumber(dto.number()); v.setDate(dto.date()); v.setType(Voucher.VoucherType.valueOf(dto.type())); v.setStatus(dto.status()==null? Voucher.VoucherStatus.DRAFT : Voucher.VoucherStatus.valueOf(dto.status())); v.setChannel(dto.channel()==null? Voucher.Channel.NONE : Voucher.Channel.valueOf(dto.channel())); v.setNarration(dto.narration()); if (dto.contactId()!=null) v.setContact(contacts.findById(dto.contactId()).orElseThrow()); else v.setContact(null); v.setBankName(dto.bankName()); v.setBankAccountNo(dto.bankAccountNo()); if (dto.lines()!=null){ for (Line l : dto.lines()){ VoucherLine vl = new VoucherLine(); vl.setVoucher(v); vl.setAccount(accounts.findById(l.accountId()).orElseThrow()); vl.setDescription(l.description()); vl.setAmount(l.amount()); vl.setTaxAmount(l.taxAmount()); v.getLines().add(vl); } } } }
Controller
java// web/VoucherController.java package com.example.treeauthapi.web; import com.example.treeauthapi.dto.VoucherDtos.CreateUpdate; import com.example.treeauthapi.model.Voucher; import com.example.treeauthapi.service.VoucherService; import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/api/vouchers") @CrossOrigin(origins={"http://localhost:5173","http://localhost:3000"}) public class VoucherController { private final VoucherService svc; public VoucherController(VoucherService svc){ this.svc = svc; } @GetMapping("/{type}") public List<Voucher> list(@PathVariable String type){ return svc.listByType(Voucher.VoucherType.valueOf(type)); } @PostMapping public Voucher create(@RequestBody @Valid CreateUpdate dto){ return svc.create(dto); } @PutMapping("/{id}") public Voucher update(@PathVariable Long id, @RequestBody @Valid CreateUpdate dto){ return svc.update(id, dto); } @DeleteMapping("/{id}") public void delete(@PathVariable Long id){ svc.delete(id); } }
model/SalesInvoice.java
javapackage com.example.treeauthapi.model; import jakarta.persistence.*; import lombok.*; import java.time.LocalDate; import java.util.*; @Entity @Table(name="sales_invoices") @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder public class SalesInvoice { @Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id; @Column(nullable=false, unique=true) private String invoiceNumber; @Column(nullable=false) private LocalDate issueDate; @ManyToOne(fetch=FetchType.LAZY) @JoinColumn(name="customer_id") private Contact customer; private Double totalExVat; private Double vatAmount; private Double totalIncVat; @OneToMany(mappedBy="invoice", cascade=CascadeType.ALL, orphanRemoval=true, fetch=FetchType.EAGER) private List<SalesInvoiceLine> lines = new ArrayList<>(); // ZATCA artifacts @Lob private String zatcaXml; // last generated UBL XML for preview/submission @Lob private String qrBase64; // TLV base64 for simplified invoice }
model/SalesInvoiceLine.java
javapackage com.example.treeauthapi.model; import jakarta.persistence.*; import lombok.*; @Entity @Table(name="sales_invoice_lines") @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder public class SalesInvoiceLine { @Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id; @ManyToOne(fetch=FetchType.LAZY) @JoinColumn(name="invoice_id") private SalesInvoice invoice; @ManyToOne(fetch=FetchType.LAZY) @JoinColumn(name="product_id") private Product product; private String description; @Column(nullable=false) private Double quantity; @Column(nullable=false) private Double unitPrice; private Double lineVatRate; // e.g., 0.15 private Double lineVatAmount; // computed private Double lineTotal; // qty*price + VAT }
Repos
java// repo/SalesInvoiceRepository.java package com.example.treeauthapi.repo; import com.example.treeauthapi.model.SalesInvoice; import org.springframework.data.jpa.repository.JpaRepository; public interface SalesInvoiceRepository extends JpaRepository<SalesInvoice, Long> { boolean existsByInvoiceNumber(String invoiceNumber); }
DTOs
java// dto/SalesInvoiceDtos.java package com.example.treeauthapi.dto; import jakarta.validation.constraints.NotNull; import java.time.LocalDate; import java.util.List; public class SalesInvoiceDtos { public record Line(Long productId, String description, double quantity, double unitPrice, Double vatRate){} public record CreateUpdate( @NotNull String invoiceNumber, @NotNull LocalDate issueDate, @NotNull Long customerId, List<Line> lines ){} }
ZATCA utilities (Phase-2 hooks + QR TLV)
java// security/zatca/ZatcaPhase2Util.java package com.example.treeauthapi.security.zatca; import com.example.treeauthapi.model.CompanySettings; import com.example.treeauthapi.model.SalesInvoice; import java.nio.charset.StandardCharsets; import java.time.format.DateTimeFormatter; import java.util.Base64; public class ZatcaPhase2Util { /** Build TLV Base64 for simplified invoice QR as per ZATCA (seller name, VAT, timestamp, total incl. VAT, VAT amount). */ public static String buildQrBase64(CompanySettings company, SalesInvoice inv){ // Tag order: 1 Seller name, 2 VAT number, 3 Timestamp, 4 Total incl VAT, 5 VAT amount String ts = inv.getIssueDate().atStartOfDay().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); // you may include offset byte[] tlv = concat( tlv(1, company.getLegalName()), tlv(2, company.getVatNumber()), tlv(3, ts), tlv(4, String.format("%.2f", inv.getTotalIncVat())), tlv(5, String.format("%.2f", inv.getVatAmount())) ); return Base64.getEncoder().encodeToString(tlv); } private static byte[] tlv(int tag, String value){ byte[] v = value.getBytes(StandardCharsets.UTF_8); return concat(new byte[]{(byte)tag}, new byte[]{(byte)v.length}, v); } private static byte[] concat(byte[]... arrs){ int len=0; for (byte[] a:arrs) len+=a.length; byte[] out=new byte[len]; int p=0; for (byte[] a: arrs){ System.arraycopy(a,0,out,p,a.length); p+=a.length; } return out; } /** Very small UBL 2.1 skeleton (NOT production-complete!). */ public static String buildUblXmlPreview(CompanySettings c, SalesInvoice inv){ // NOTE: Replace with proper UBL library mapping; this is a preview stub. return """ <?xml version="1.0" encoding="UTF-8"?> <Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"> <cbc:ProfileID>reporting:1.0</cbc:ProfileID> <cbc:ID>%s</cbc:ID> <cbc:IssueDate>%s</cbc:IssueDate> <cac:AccountingSupplierParty> <cac:Party> <cac:PartyName><cbc:Name>%s</cbc:Name></cac:PartyName> <cac:PartyTaxScheme><cbc:CompanyID>%s</cbc:CompanyID></cac:PartyTaxScheme> <cac:PostalAddress><cbc:CountrySubentity>%s</cbc:CountrySubentity><cac:Country><cbc:IdentificationCode>%s</cbc:IdentificationCode></cac:Country></cac:PostalAddress> </cac:Party> </cac:AccountingSupplierParty> <cac:AccountingCustomerParty> <cac:Party> <cac:PartyName><cbc:Name>%s</cbc:Name></cac:PartyName> </cac:Party> </cac:AccountingCustomerParty> <cac:LegalMonetaryTotal> <cbc:TaxExclusiveAmount currencyID="SAR">%.2f</cbc:TaxExclusiveAmount> <cbc:TaxInclusiveAmount currencyID="SAR">%.2f</cbc:TaxInclusiveAmount> <cbc:PayableAmount currencyID="SAR">%.2f</cbc:PayableAmount> </cac:LegalMonetaryTotal> </Invoice> """.formatted(inv.getInvoiceNumber(), inv.getIssueDate(), c.getLegalName(), c.getVatNumber(), c.getCity(), c.getCountryCode(), inv.getCustomer()!=null? inv.getCustomer().getName():"Buyer", inv.getTotalExVat(), inv.getTotalIncVat(), inv.getTotalIncVat()); } }
Why these fields/methods?
ZATCA Phase-2 requires UBL 2.1 XML, QR codes (TLV Base64 with defined tags) for simplified invoices, device onboarding/CSID, and cryptographic stamping/clearance or reporting via the FATOORA platform. See the official docs for exact fields and security requirements: ZATCA Detailed Guidelines, XML Implementation Standard, and the QR Code guide. (Zakat, Tax and Customs Authority)
Service + Controller
java// service/SalesInvoiceService.java package com.example.treeauthapi.service; import com.example.treeauthapi.dto.SalesInvoiceDtos.CreateUpdate; import com.example.treeauthapi.dto.SalesInvoiceDtos.Line; import com.example.treeauthapi.model.*; import com.example.treeauthapi.repo.*; import com.example.treeauthapi.security.zatca.ZatcaPhase2Util; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class SalesInvoiceService { private final SalesInvoiceRepository invoices; private final ContactRepository contacts; private final ProductRepository products; private final CompanySettingsRepository companyRepo; public SalesInvoiceService(SalesInvoiceRepository i, ContactRepository c, ProductRepository p, CompanySettingsRepository companyRepo){ this.invoices = i; this.contacts = c; this.products = p; this.companyRepo = companyRepo; } @Transactional public SalesInvoice create(CreateUpdate dto){ return saveEntity(new SalesInvoice(), dto); } @Transactional public SalesInvoice update(Long id, CreateUpdate dto){ SalesInvoice inv = invoices.findById(id).orElseThrow(); inv.getLines().clear(); return saveEntity(inv, dto); } @Transactional(readOnly = true) public SalesInvoice get(Long id){ return invoices.findById(id).orElseThrow(); } @Transactional public void delete(Long id){ invoices.deleteById(id); } @Transactional public SalesInvoice generateArtifacts(Long id){ SalesInvoice inv = invoices.findById(id).orElseThrow(); CompanySettings c = companyRepo.findAll().stream().findFirst().orElseThrow(); inv.setZatcaXml(ZatcaPhase2Util.buildUblXmlPreview(c, inv)); inv.setQrBase64(ZatcaPhase2Util.buildQrBase64(c, inv)); return inv; } private SalesInvoice saveEntity(SalesInvoice inv, CreateUpdate dto){ if (inv.getId()==null && invoices.existsByInvoiceNumber(dto.invoiceNumber())) throw new IllegalArgumentException("Duplicate invoice number"); inv.setInvoiceNumber(dto.invoiceNumber()); inv.setIssueDate(dto.issueDate()); inv.setCustomer(contacts.findById(dto.customerId()).orElseThrow()); double exVat=0, vat=0, inc=0; if (dto.lines()!=null){ for (Line l : dto.lines()){ SalesInvoiceLine line = new SalesInvoiceLine(); line.setInvoice(inv); if (l.productId()!=null) line.setProduct(products.findById(l.productId()).orElse(null)); line.setDescription(l.description()); line.setQuantity(l.quantity()); line.setUnitPrice(l.unitPrice()); double r = l.vatRate()==null?0.15:l.vatRate(); // default 15% KSA double lineEx = l.quantity()*l.unitPrice(); double lineVat = lineEx * r; double lineTot = lineEx + lineVat; line.setLineVatRate(r); line.setLineVatAmount(lineVat); line.setLineTotal(lineTot); inv.getLines().add(line); exVat += lineEx; vat += lineVat; inc += lineTot; } } inv.setTotalExVat(exVat); inv.setVatAmount(vat); inv.setTotalIncVat(inc); return invoices.save(inv); } }
java// web/SalesInvoiceController.java package com.example.treeauthapi.web; import com.example.treeauthapi.dto.SalesInvoiceDtos.CreateUpdate; import com.example.treeauthapi.model.SalesInvoice; import com.example.treeauthapi.service.SalesInvoiceService; import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/invoices/sales") @CrossOrigin(origins={"http://localhost:5173","http://localhost:3000"}) public class SalesInvoiceController { private final SalesInvoiceService svc; public SalesInvoiceController(SalesInvoiceService svc){ this.svc = svc; } @PostMapping public SalesInvoice create(@RequestBody @Valid CreateUpdate dto){ return svc.create(dto); } @PutMapping("/{id}") public SalesInvoice update(@PathVariable Long id, @RequestBody @Valid CreateUpdate dto){ return svc.update(id, dto); } @GetMapping("/{id}") public SalesInvoice get(@PathVariable Long id){ return svc.get(id); } @DeleteMapping("/{id}") public void delete(@PathVariable Long id){ svc.delete(id); } /** Build/attach UBL XML + TLV QR (preview). For production, replace with proper onboarding, signing, clearance/reporting. */ @PostMapping("/{id}/zatca/preview") public SalesInvoice preview(@PathVariable Long id){ return svc.generateArtifacts(id); } }
⚠️ Production ZATCA Phase-2 requires device onboarding (CSID), cryptographic stamping (ECDSA), clearance (B2B) / reporting within 24h (B2C), XML schema validations and security controls. What I added is a preview path and the TLV QR generator per the specs; integrate your real device certificate & APIs to be compliant. See ZATCA Simplified Technical Guide and Implementation Standard (UBL + security, clearance/reporting). (Zakat, Tax and Customs Authority)
Add types & APIs, then simple pages for Vouchers and Sales Invoices.
src/types.ts (additions)tsexport type Contact = { id: number; name: string; vatNumber?: string | null; type: 'CUSTOMER'|'SUPPLIER' }; export type CompanySettings = { id?: number; legalName: string; vatNumber: string; addressLine?: string; city?: string; countryCode?: string; csid?: string; productionMode?: 'sandbox'|'production'; privateKeyPem?: string; certificatePem?: string; }; export type VoucherLine = { accountId: number; description?: string; amount: number; taxAmount?: number|null }; export type Voucher = { id: number; number: string; date: string; type: 'PAYMENT_REQUEST'|'CASH_RECEIPT'|'CASH_PAYMENT'|'BANK_RECEIPT'|'BANK_PAYMENT'|'PURCHASE'; status: 'DRAFT'|'POSTED'; channel: 'CASH'|'BANK'|'NONE'; contact?: Contact|null; bankName?: string|null; bankAccountNo?: string|null; narration?: string|null; lines: Array<{ id:number; account:{id:number;name:string}; description?:string; amount:number; taxAmount?:number|null }>; }; export type SalesInvoiceLine = { productId?: number|null; description?: string; quantity: number; unitPrice: number; vatRate?: number|null }; export type SalesInvoice = { id: number; invoiceNumber: string; issueDate: string; customer: Contact; totalExVat: number; vatAmount: number; totalIncVat: number; zatcaXml?: string|null; qrBase64?: string|null; lines: Array<{ id:number; description?:string; quantity:number; unitPrice:number; lineVatRate:number; lineVatAmount:number; lineTotal:number }>; };
src/api.ts (additions)ts// Company export async function getCompany(): Promise<any> { const r = await fetch('http://localhost:8080/api/company', { headers: { ...authHeader() }}); if (!r.ok) throw new Error('Company fetch failed'); return r.json(); } export async function saveCompany(payload: any): Promise<any> { const r = await fetch('http://localhost:8080/api/company', { method:'PUT', headers: {'Content-Type':'application/json', ...authHeader()}, body: JSON.stringify(payload) }); if (!r.ok) throw new Error('Company save failed'); return r.json(); } // Contacts export async function listContacts(): Promise<any[]> { const r = await fetch('http://localhost:8080/api/contacts', { headers: { ...authHeader() }}); if (!r.ok) throw new Error('Contacts failed'); return r.json(); } // (Create a minimal contacts controller if you want CRUD; for brevity, you can seed via DB) // Vouchers export async function listVouchers(type: string){ const r = await fetch(`http://localhost:8080/api/vouchers/${type}`, { headers:{...authHeader()}}); if (!r.ok) throw new Error('Voucher list failed'); return r.json(); } export async function createVoucher(payload: any){ const r = await fetch('http://localhost:8080/api/vouchers', { method:'POST', headers:{'Content-Type':'application/json', ...authHeader()}, body: JSON.stringify(payload) }); if (!r.ok) throw new Error('Voucher create failed'); return r.json(); } export async function updateVoucher(id:number, payload:any){ const r = await fetch(`http://localhost:8080/api/vouchers/${id}`, { method:'PUT', headers:{'Content-Type':'application/json', ...authHeader()}, body: JSON.stringify(payload) }); if (!r.ok) throw new Error('Voucher update failed'); return r.json(); } export async function deleteVoucher(id:number){ const r = await fetch(`http://localhost:8080/api/vouchers/${id}`, { method:'DELETE', headers:{...authHeader()} }); if (!r.ok) throw new Error('Voucher delete failed'); } // Sales Invoices export async function createSalesInvoice(payload:any){ const r = await fetch('http://localhost:8080/api/invoices/sales', { method:'POST', headers:{'Content-Type':'application/json', ...authHeader()}, body: JSON.stringify(payload) }); if (!r.ok) throw new Error('Sales invoice create failed'); return r.json(); } export async function updateSalesInvoice(id:number, payload:any){ const r = await fetch(`http://localhost:8080/api/invoices/sales/${id}`, { method:'PUT', headers:{'Content-Type':'application/json', ...authHeader()}, body: JSON.stringify(payload) }); if (!r.ok) throw new Error('Sales invoice update failed'); return r.json(); } export async function getSalesInvoice(id:number){ const r = await fetch(`http://localhost:8080/api/invoices/sales/${id}`, { headers:{...authHeader()} }); if (!r.ok) throw new Error('Sales invoice fetch failed'); return r.json(); } export async function previewZatca(id:number){ const r = await fetch(`http://localhost:8080/api/invoices/sales/${id}/zatca/preview`, { method:'POST', headers:{...authHeader()} }); if (!r.ok) throw new Error('ZATCA preview failed'); return r.json(); }
src/pages/Vouchers.tsx
tsximport React, { useEffect, useState } from 'react'; import { createVoucher, deleteVoucher, listVouchers } from '../api'; const TYPES = ['PAYMENT_REQUEST','CASH_RECEIPT','CASH_PAYMENT','BANK_RECEIPT','BANK_PAYMENT','PURCHASE'] as const; type Type = typeof TYPES[number]; export default function VouchersPage(){ const [type,setType] = useState<Type>('PAYMENT_REQUEST'); const [items,setItems] = useState<any[]>([]); const [loading,setLoading]=useState(false); const load = async()=>{ setLoading(true); try{ setItems(await listVouchers(type)); } finally{ setLoading(false);} }; useEffect(()=>{ load(); },[type]); const add = async()=>{ const number = prompt('Voucher number?'); if(!number) return; const date = prompt('Date (YYYY-MM-DD)?', new Date().toISOString().slice(0,10))!; const channel = (type==='CASH_RECEIPT'||type==='CASH_PAYMENT')?'CASH':(type==='BANK_RECEIPT'||type==='BANK_PAYMENT')?'BANK':'NONE'; const payload = { number, date, type, status:'DRAFT', channel, narration:'', lines: [] }; await createVoucher(payload); await load(); }; const del = async(id:number)=>{ if(!confirm('Delete voucher?')) return; await deleteVoucher(id); await load(); }; return ( <div> <div className="tabs"> {TYPES.map(t => <button key={t} className={t===type?'active':''} onClick={()=>setType(t as Type)}>{t.replace('_',' ')}</button>)} <span className="spacer" /> <button onClick={add}>+ New</button> </div> {loading? <div className="muted">Loading…</div> : <div className="tree"> {items.map(v => ( <div key={v.id} className="row" style={{gap:12}}> <strong>{v.number}</strong> <span className="muted">{v.date}</span> <span className="muted">{v.type}</span> <span className="spacer" /> <button className="link danger" onClick={()=>del(v.id)}>Delete</button> </div> ))} </div>} </div> ); }
src/pages/SalesInvoices.tsx
tsximport React, { useState } from 'react'; import { createSalesInvoice, previewZatca } from '../api'; export default function SalesInvoicesPage(){ const [created,setCreated] = useState<any|null>(null); const make = async()=>{ const invoiceNumber = prompt('Invoice number?'); if(!invoiceNumber) return; const issueDate = prompt('Issue date (YYYY-MM-DD)?', new Date().toISOString().slice(0,10))!; const customerId = Number(prompt('Customer ID (seed in DB for now)?')); if(!customerId) return; const payload = { invoiceNumber, issueDate, customerId, lines: [{ productId: null, description:'Item A', quantity:1, unitPrice:100, vatRate:0.15 }] }; const inv = await createSalesInvoice(payload); setCreated(inv); }; const doPreview = async()=>{ if (!created) return; const inv = await previewZatca(created.id); setCreated(inv); }; return ( <div> <div className="tabs"> <button onClick={make}>+ New Sales Invoice</button> {created && <button onClick={doPreview}>Generate ZATCA XML + QR (preview)</button>} </div> {created && ( <div className="tree" style={{padding:12}}> <h3>{created.invoiceNumber} — {created.issueDate}</h3> <p>Total: SAR {created.totalIncVat?.toFixed(2)} (VAT: {created.vatAmount?.toFixed(2)})</p> {created.qrBase64 && <div><small>TLV QR (Base64):</small><pre style={{whiteSpace:'pre-wrap'}}>{created.qrBase64}</pre></div>} {created.zatcaXml && <div><small>UBL XML (preview):</small><pre style={{whiteSpace:'pre-wrap', maxHeight:300, overflow:'auto'}}>{created.zatcaXml}</pre></div>} </div> )} </div> ); }
In src/App.tsx, add tabs/routes (or just swap components):
tsx// inside Main() where you render app content after login: import VouchersPage from './pages/Vouchers'; import SalesInvoicesPage from './pages/SalesInvoices'; function MainAppTabs(){ const [tab,setTab] = React.useState<'trees'|'vouchers'|'sales'>('trees'); return ( <> <div className="tabs"> <button className={tab==='trees'?'active':''} onClick={()=>setTab('trees')}>Trees</button> <button className={tab==='vouchers'?'active':''} onClick={()=>setTab('vouchers')}>Vouchers</button> <button className={tab==='sales'?'active':''} onClick={()=>setTab('sales')}>Sales Invoices</button> </div> {tab==='trees' && <TreesPage/>} {tab==='vouchers' && <VouchersPage/>} {tab==='sales' && <SalesInvoicesPage/>} </> ); }
…and render <MainAppTabs/> where you previously rendered <TreesPage/>.
erpsoft (configure credentials once).Voucher + VoucherLine covers Payment Request, Cash/Bank Receipt/Payment, Purchase.SalesInvoice + SalesInvoiceLine + ZATCA hooks:
/api/invoices/sales/{id}/zatca/preview returns a UBL preview + TLV Base64 QR (simplified invoice requirement). See ZATCA specs (UBL, QR TLV, Integration/Phase-2): (Zakat, Tax and Customs Authority)listContacts() stub).If you want, I can add:
provide me right from scratch like create-react-app innosphere. innosphere is the application name. also it should create database if not exist as innosphere. instead of using example use innosphere in the spring boot as application name.
var cannot be resolved to a typepackage com.innosphere.security;
import io.jsonwebtoken.Claims;
import jakarta.servlet.;
import jakarta.servlet.http.;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.util.List;
@Component public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwt; public JwtAuthFilter(JwtUtil jwt){ this.jwt=jwt; }
@Override protected void doFilterInternal(HttpServletRequest req,HttpServletResponse res,FilterChain chain) throws ServletException,IOException {
String a=req.getHeader(HttpHeaders.AUTHORIZATION);
if(a!=null && a.startsWith("Bearer ")){ try{
var j=jwt.parse(a.substring(7)); Claims c=j.getBody();
var tok=new UsernamePasswordAuthenticationToken(c.getSubject(),null,List.of()); tok.setDetails(c);
SecurityContextHolder.getContext().setAuthentication(tok);
}catch(Exception ignored){} }
chain.doFilter(req,res);
}
}
You’re getting var cannot be resolved to a type because the source level is below Java 10 (local-variable var was added in Java 10). With Spring Boot 3.x you should use Java 17. You can either (A) upgrade your JDK/project to Java 17, or (B) remove var and other newer APIs so it compiles on older levels. Here’s both fixes—pick one.
pom.xml (ensure these are present)
xml<properties> <java.version>17</java.version> <maven.compiler.release>17</maven.compiler.release> </properties>
Make sure your IDE/Project SDK is JDK 17, and run:
bashmvn -v # should show a JDK 17 runtime mvn clean compile
Your existing filter will then compile as-is.
var + List.of)Replace your filter with explicit types (works back to Java 8). Also add the missing imports.
src/main/java/com/innosphere/security/JwtAuthFilter.java
javapackage com.innosphere.security; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.Collections; @Component public class JwtAuthFilter extends OncePerRequestFilter { private final JwtUtil jwt; public JwtAuthFilter(JwtUtil jwt) { this.jwt = jwt; } @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException { String a = req.getHeader(HttpHeaders.AUTHORIZATION); if (a != null && a.startsWith("Bearer ")) { try { Jws<Claims> j = jwt.parse(a.substring(7)); // parse the token Claims c = j.getBody(); UsernamePasswordAuthenticationToken tok = new UsernamePasswordAuthenticationToken( c.getSubject(), // principal = email/username null, Collections.emptyList() // no authorities here ); tok.setDetails(c); SecurityContextHolder.getContext().setAuthentication(tok); } catch (Exception ignored) { // token invalid/expired — leave context unauthenticated } } chain.doFilter(req, res); } }
Notes:
- Add
import io.jsonwebtoken.Jws;andimport java.util.Collections;.- If you’re on Spring Boot 3.x, you still need JDK 17+ overall (even if this class compiles on older Java). If you must stay on Java 8, use Spring Boot 2.7.x instead.
In your application.properties, use:
propertiesspring.datasource.url=jdbc:mysql://localhost:3306/innosphere?createDatabaseIfNotExist=true&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC spring.datasource.username=your_user spring.datasource.password=your_password spring.jpa.hibernate.ddl-auto=update
This creates the innosphere database if it doesn’t exist (user must have permission), and JPA will generate tables from your entities.
package com.innosphere.dto;
import jakarta.validation.constraints.*;
public class AuthDtos {
public record RegisterRequest(@NotBlank String displayName, @Email @NotBlank String email, @Size(min=6) String password){}
public record LoginRequest(@Email @NotBlank String email, @NotBlank String password){}
public record AuthResponse(String token, String displayName, String email){}
}Multiple markers at this line
- 'record' is not a valid type name; it is a restricted identifier and not allowed as a type identifier in Java 1.8
- This method must return a result of type recordMultiple markers at this line
- 'record' is not a valid type name; it is a restricted identifier and not allowed as a type identifier in Java 1.8
- This method must return a result of type recordMultiple markers at this line
- 'record' is not a valid type name; it is a restricted identifier and not allowed as a type identifier in Java 1.8
- This method must return a result of type record
You’re compiling with Java 8, but your DTO uses record (needs Java 16+) and your imports are jakarta.* (Spring Boot 3 → Java 17+).
So you have two choices:
mvn -v should show a 17.x JDK.pom.xml:xml<properties> <java.version>17</java.version> <maven.compiler.release>17</maven.compiler.release> </properties>
Now your record DTO compiles as-is, and jakarta.* stays correct for Boot 3.
If you cannot upgrade Java:
2.7.18), which supports Java 8.jakarta.* imports to javax.*.record DTOs with regular POJOs.If you’re on Boot 3 (jakarta) but still want POJOs, this compiles on Java 17+.
If you’re on Boot 2.7 (javax), just change the imports accordingly.
javapackage com.innosphere.dto; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; public final class AuthDtos { public static final class RegisterRequest { @NotBlank private String displayName; @Email @NotBlank private String email; @Size(min = 6) private String password; public RegisterRequest() {} public RegisterRequest(String displayName, String email, String password) { this.displayName = displayName; this.email = email; this.password = password; } public String getDisplayName() { return displayName; } public void setDisplayName(String displayName) { this.displayName = displayName; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } } public static final class LoginRequest { @Email @NotBlank private String email; @NotBlank private String password; public LoginRequest() {} public LoginRequest(String email, String password) { this.email = email; this.password = password; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } } public static final class AuthResponse { private String token; private String displayName; private String email; public AuthResponse() {} public AuthResponse(String token, String displayName, String email) { this.token = token; this.displayName = displayName; this.email = email; } public String getToken() { return token; } public void setToken(String token) { this.token = token; } public String getDisplayName() { return displayName; } public void setDisplayName(String displayName) { this.displayName = displayName; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } } }
If you truly must stay on Java 8, also:
- Set Spring Boot to 2.7.18 in
pom.xml.- Change imports:
jakarta.validation.*→javax.validation.*
jakarta.servlet.*→javax.servlet.*
In application.properties (works in both options):
propertiesspring.datasource.url=jdbc:mysql://localhost:3306/innosphere?createDatabaseIfNotExist=true&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC spring.datasource.username=your_user spring.datasource.password=your_password spring.jpa.hibernate.ddl-auto=update
record code compiles.record with POJOs + use javax.*.package com.innosphere.dto;
import jakarta.validation.constraints.*;
public record CreateUpdateAccountDto(@NotBlank String name,String code,Long parentId,Integer sortOrder){}Multiple markers at this line
- Syntax error on token(s), misplaced construct(s)
- Syntax error on token(s), misplaced construct(s)Syntax error on token(s), misplaced construct(s)
You’re seeing that error because record needs Java 16+ (and Spring Boot 3 uses Jakarta + expects Java 17). Your project/IDE is compiling with Java 8/11, so record is treated as an invalid type.
You have two ways to fix it:
pom.xml:
xml<properties> <java.version>17</java.version> <maven.compiler.release>17</maven.compiler.release> </properties>
record DTO compiles as-is.record with a POJO)Drop this in src/main/java/com/innosphere/dto/CreateUpdateAccountDto.java to replace the record:
javapackage com.innosphere.dto; import jakarta.validation.constraints.NotBlank; // If you're on Spring Boot 2.7 / Java 8 replace the import with: // import javax.validation.constraints.NotBlank; public class CreateUpdateAccountDto { @NotBlank private String name; private String code; private Long parentId; private Integer sortOrder; public CreateUpdateAccountDto() {} public CreateUpdateAccountDto(String name, String code, Long parentId, Integer sortOrder) { this.name = name; this.code = code; this.parentId = parentId; this.sortOrder = sortOrder; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getCode() { return code; } public void setCode(String code) { this.code = code; } public Long getParentId() { return parentId; } public void setParentId(Long parentId) { this.parentId = parentId; } public Integer getSortOrder() { return sortOrder; } public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; } }
If you’re on Spring Boot 2.7 + Java 8, switch all
jakarta.*imports tojavax.*across the project.
Any other record types (e.g., CreateUpdateProductDto, AuthDtos.RegisterRequest, etc.) must be converted to POJOs the same way if you stay on an older Java version.
In application.properties:
propertiesspring.datasource.url=jdbc:mysql://localhost:3306/innosphere?createDatabaseIfNotExist=true&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC spring.datasource.username=your_user spring.datasource.password=your_password spring.jpa.hibernate.ddl-auto=update
If you want, tell me which other DTO classes are still records and I’ll paste POJO versions for each.